The logon chain top to bottom: Winlogon starts LogonUI, which loads the credential provider. The provider builds a blob from the input, and if the code is wrong it never builds one, so the logon stops. Otherwise the blob goes to LSA and on to an authentication package, where the password is actually checked. A normal provider never learns if the password was right, ours asks LSA first with LogonUser.
Open original
The logon chain top to bottom: Winlogon starts LogonUI, which loads the credential provider. The provider builds a blob from the input, and if the code is wrong it never builds one, so the logon stops. Otherwise the blob goes to LSA and on to an authentication package, where the password is actually checked. A normal provider never learns if the password was right, ours asks LSA first with LogonUser.
enroll.exe writes the DPAPI-encrypted secret, the state value and two presence markers for Part 2 into the registry. TacProvider.dll reads and decrypts them at logon, checks the password first and then the code, holds a file lock while it updates the state, logs every attempt and hides the other tiles. Only when password and code are correct it passes the credentials down the chain LSA, Negotiate, MSV1_0.
Open original
enroll.exe writes the DPAPI-encrypted secret, the state value and two presence markers for Part 2 into the registry. TacProvider.dll reads and decrypts them at logon, checks the password first and then the code, holds a file lock while it updates the state, logs every attempt and hides the other tiles. Only when password and code are correct it passes the credentials down the chain LSA, Negotiate, MSV1_0.
Time steps of 30 seconds on a line. The last accepted step is N, and the current time is in N too. Every step up to and including lastStep is refused, also an old step M from a code that someone wrote down. The window is the current step plus or minus one, so only N+1 is left. With == instead of <=, the code of N-1 and the old code M would still work.
Open original
Time steps of 30 seconds on a line. The last accepted step is N, and the current time is in N too. Every step up to and including lastStep is refused, also an old step M from a code that someone wrote down. The window is the current step plus or minus one, so only N+1 is left. With == instead of <=, the code of N-1 and the old code M would still work.
The checks in GetSerialization from top to bottom. The name has to resolve to the SID of the tile and the password has to be correct before the code is looked at. Otherwise the tile says the user name or password is incorrect, event 107, and nothing is counted. Then, under the state lock: not enrolled is event 105, locked is event 103, a wrong or already used code is event 101 or 102 and counts toward the lock, and a state that can't be saved is event 106. Only when everything passes, the blob is packed and goes to LSA, event 100, and LSA reports the result back, event 108.
Open original
The checks in GetSerialization from top to bottom. The name has to resolve to the SID of the tile and the password has to be correct before the code is looked at. Otherwise the tile says the user name or password is incorrect, event 107, and nothing is counted. Then, under the state lock: not enrolled is event 105, locked is event 103, a wrong or already used code is event 101 or 102 and counts toward the lock, and a state that can't be saved is event 106. Only when everything passes, the blob is packed and goes to LSA, event 100, and LSA reports the result back, event 108.
RDP with Network Level Authentication as a sequence. 1: the client sends name and password over CredSSP, and LSA on the server checks them with a network logon, without a code. With Part 2 an enrolled account is refused right there. 2: the session starts and LogonUI loads the providers and the filter. 3: UpdateRemoteCredential in our filter sends the credential to our provider. 4: SetSerialization in our provider unpacks name and password. 5: the tile is filled in and the user types the code. 6: GetSerialization checks password and code and sends the blob to LSA as a RemoteInteractive logon. 7: LSA reports the result.
Open original
RDP with Network Level Authentication as a sequence. 1: the client sends name and password over CredSSP, and LSA on the server checks them with a network logon, without a code. With Part 2 an enrolled account is refused right there. 2: the session starts and LogonUI loads the providers and the filter. 3: UpdateRemoteCredential in our filter sends the credential to our provider. 4: SetSerialization in our provider unpacks name and password. 5: the tile is filled in and the user types the code. 6: GetSerialization checks password and code and sends the blob to LSA as a RemoteInteractive logon. 7: LSA reports the result.
build.bat in the x64 Native Tools Command Prompt
Open original
build.bat in the x64 Native Tools Command Prompt
The TheAdminCafe folder under C:\Program Files
Open original
The TheAdminCafe folder under C:\Program Files
TacProvider.dll, enroll.exe and the .reg files in the folder
Open original
TacProvider.dll, enroll.exe and the .reg files in the folder
reg import register.reg in an admin PowerShell
Open original
reg import register.reg in an admin PowerShell
The normal logon screen with "Sign-in options"
Open original
The normal logon screen with "Sign-in options"
Our tile with username, password and one-time code
Open original
Our tile with username, password and one-time code
Creating the user test and enrolling it with enroll.exe
Open original
Creating the user test and enrolling it with enroll.exe
Adding the secret in the Google Authenticator
Open original
Adding the secret in the Google Authenticator
The first code in the app
Open original
The first code in the app
The 2FA tile with username, password and code filled in
Open original
The 2FA tile with username, password and code filled in
Invalid one-time code.
Open original
Invalid one-time code.
Applying user settings: the logon works
Open original
Applying user settings: the logon works
Boot paths from top to bottom. From the UEFI firmware a USB stick can boot another OS that works on the disk offline, unless a UEFI password and a fixed boot order stop it and BitLocker keeps the disk encrypted. BitLocker with TPM and PIN stops every path below it for someone without the PIN, TPM only unlocks all of them. The Windows Boot Manager then boots normally into LogonUI with our tile and filter, or into the recovery environment. There, Startup Settings offer Safe Mode, where third-party tiles don't load and the password is enough, and a command prompt for offline registry edits, which asks for the BitLocker recovery key first. reagentc /disable removes the recovery environment.
Open original
Boot paths from top to bottom. From the UEFI firmware a USB stick can boot another OS that works on the disk offline, unless a UEFI password and a fixed boot order stop it and BitLocker keeps the disk encrypted. BitLocker with TPM and PIN stops every path below it for someone without the PIN, TPM only unlocks all of them. The Windows Boot Manager then boots normally into LogonUI with our tile and filter, or into the recovery environment. There, Startup Settings offer Safe Mode, where third-party tiles don't load and the password is enough, and a command prompt for offline registry edits, which asks for the BitLocker recovery key first. reagentc /disable removes the recovery environment.
the admin café

Menu / Windows

Build Your Own 2FA for the Windows Logon

How a Credential Provider works and how to build a working TOTP 2FA for local Windows accounts in C++. With rate limiting, replay protection and logging.

Lukas·Sep 30, 2026·updated ·60 min · French Press 60 min · French Press· Advanced · Strong

Foreword

This is going to be a long one. Make sure you have enough coffee.

In this entry I would like to show you how 2FA on the Windows logon screen works. Products like AuthLite do exactly this and I always wanted to know what happens behind the scenes. So I built it myself.

At the end we have a working 2FA for local Windows accounts. You log in with your password and a 6-digit code from your authenticator app (Google Authenticator, Microsoft Authenticator or whatever you use).

I have not found a guide on the internet that explains the whole thing from start to finish. Most tutorials just add a text field to the logon screen and call it 2FA. As you will see, that is not enough.

All the code is in this article. You can build everything with one command. If you'd rather clone it, the project is also on GitHub: Doppio. A doppio is a double espresso. Two shots, two factors.

I am by no means claiming that this is production ready. It is a demo for learning. I will show you exactly where the limits are.

Important: Only do this in a VM and take a snapshot before every install. If the Credential Provider is broken, you can no longer log in. There is no error message, you are just locked out.

note

Changelog, October 2026. I changed this article twice after it first went up. The details are next to the code.

  • Password before code. After a code audit, the tile checks the password first and only then the code. Before, anybody could lock an account by typing random codes. There are smaller changes too.
  • A review against Microsoft's documentation. For this part it changed less than for Part 2. The store has a second file, store_index.cpp, so the build lines below have one file more. enroll.exe /list shows who is enrolled. The filter only counts real secrets, and it only takes over an RDP credential while it hides the other tiles. And install-doppio.ps1 in the repo turns the filter on only after it checked that every enabled account is enrolled.

Prerequisites

  • A Windows 10/11 VM (please really use a VM)
  • Visual Studio 2026 or 2022 (Community is enough) or the Build Tools, with the "Desktop development with C++" workload. The Windows SDK is included.
  • Nothing extra on the test VM. The C++ runtime is linked statically (/MT), so no Visual C++ Redistributable is needed.
  • An authenticator app on your phone

I built and tested everything on one Windows VM in my homelab. Visual Studio runs on the same VM, so there is nothing to copy between machines.

What this protects and what not

Before we start, you should know what this demo can do.

Protected: The interactive logon and the unlock of local accounts. This means the console and RDP sessions that end up on our tile. Without a valid code there is no logon. The tile does ask Windows if the password is correct before it checks the code. The chapter about the tile explains why.

Not protected: Everything that does not go through our tile. Network logons (\\host\share), runas, scheduled tasks, WinRM, PowerShell remoting, WMI and UAC prompts.

The UAC part is important for local accounts. If someone sits at an unlocked session and knows the password of a local admin, they can elevate without the code.

You can close most of the network paths with the usual hardening, for example "Deny access to this computer from the network" for local accounts. This is not enabled by default. Out of the box a local account can still log on via SMB with just the password.

Physical access is a different story. Whoever can reboot the machine can go around the logon screen completely, for example with Safe Mode or by editing the registry offline. No Credential Provider can stop that. This has its own chapter: "Attacks that never touch the tile". The next chapter shows where this limit comes from.

How the Windows logon works

From GINA to Credential Providers

Up to Windows XP you had to replace the msgina.dll if you wanted to change the logon. GINA stands for "Graphical Identification and Authentication". There could only be one GINA per machine. If two vendors wanted to change the logon, you had a problem. And if the GINA was broken, nobody could log in anymore.

With Windows Vista Microsoft replaced this with Credential Providers. These are COM objects (DLLs) and there can be as many as you want. Each Credential Provider can show one or more tiles on the logon screen. This is still the model today, from Windows 7 to Windows 11 and Server 2025. The demo itself needs at least Windows 8 or Server 2012, because it uses ICredentialProviderSetUserArray, which came with Windows 8.

The logon chain

When you log in, the following happens:

The Credential Provider does not check the password itself. It only collects the input and packs it into a blob. The check happens in LSA (Local Security Authority, lsass.exe). For a local account LSA passes the blob to MSV1_0, which checks it against the local SAM database. Normally the provider never finds out if the password was correct. It just hands over the blob. Ours asks Windows first, with a separate LogonUser call, for reasons that come in the chapter about the tile.

This has two consequences for everything that follows:

  1. The provider can refuse to create the blob. No blob, no logon. This is our 2FA: if the code is wrong, we don't create the blob.
  2. The provider only sees logons that go through its tile. Network logons, runas and services go to LSA another way, and our code is never called for them. UAC credential prompts do load credential providers, but in their own scenario (CPUS_CREDUI), and ours stays out of it. More about that below.

The second point is a limit of every Credential Provider. That's why AuthLite has two parts: a Credential Provider for the tile and a component directly in LSA. There every logon is checked, no matter where it comes from. The LSA part is a lot harder and more risky. If you have a bug there, lsass crashes and takes the whole machine with it. In this article I build the first part.

The interfaces

A Credential Provider is a set of COM interfaces. In this project I use five of them:

  • ICredentialProvider is the provider itself. LogonUI asks it how many tiles there are (GetCredentialCount), which fields a tile has (GetFieldDescriptorCount, GetFieldDescriptorAt) and for the tiles themselves (GetCredentialAt). With SetUsageScenario LogonUI tells it the situation, more about this below.
  • ICredentialProviderCredential is one tile. It stores the input (SetStringValue) and has the most important function: GetSerialization. This is where the input becomes the blob for LSA.
  • ICredentialProviderCredential2 is the same with one more function: GetUserSid. With this a tile belongs to a user account. Windows then shows the name and the round profile picture of that account, like on the normal tiles.
  • ICredentialProviderSetUserArray belongs to the provider. LogonUI uses SetUserArray to give us the list of users that should be shown. For every user I read the name (PC\user) and the SID.
  • ICredentialProviderFilter decides which providers are shown. I use it to hide password, PIN and Windows Hello. Without the filter you could choose the normal password tile and skip the code.

Usage scenarios

Windows calls the providers in different situations. These are called usage scenarios:

ScenarioWhen
CPUS_LOGONNormal logon
CPUS_UNLOCK_WORKSTATIONUnlock after locking the screen
CPUS_CHANGE_PASSWORDPassword change
CPUS_CREDUICredential prompts, including UAC
CPUS_PLAPPre-logon access (e.g. VPN before logon)

We only support logon and unlock. For everything else the provider returns E_NOTIMPL, and that is on purpose. CPUS_CREDUI is used for every UAC prompt and every application that asks for Windows credentials. If a provider does something wrong there, credential prompts can stop working in the whole system. So only support what you really need.

This is also where the UAC gap comes from. We don't take part in CPUS_CREDUI, so UAC still uses the normal password prompt without a code.

Architecture of the demo

The demo consists of three parts that share the same TOTP code:

The secret is encrypted with DPAPI and stored in the registry. The key is only readable for SYSTEM and administrators. Why both are needed is explained in the chapter about the secret store.

The project consists of these files:

FilePurpose
totp.h/.cppTOTP (RFC 6238) and Base32 with CNG (bcrypt)
store.h/.cppSecret store with DPAPI and a protected registry key, state per account
store_index.cpp, storekeys.hThe reads of the two indexes for Part 2, and the registry paths
verify.h/.cppReplay protection and lock after wrong codes
statelock.h/.cppLock so that parallel logons don't change the state at the same time
eventlog.h/.cppEvents in the Application log
enroll.cppConsole tool to enroll a user
common.hFields of the tile
CTacProvider.h/.cppThe provider, one tile per local user
CTacCredential.h/.cppOne tile with the code check
CTacFilter.h/.cppThe filter (ICredentialProviderFilter)
helpers.h/.cppHelper functions for the serialization
dll.h/.cpp, guid.h, TacProvider.defCOM stuff: class factory, exports, CLSIDs
build.bat, CMakeLists.txtBuild
new-guids.ps1Creates your own CLSIDs
register.reg, register-filter.reg, unregister.regRegistration in Windows
install-doppio.ps1Copies and registers in one go, the filter only on request
deny-noninteractive.ps1From Part 2: denies network, batch and service logon for an account
tests/totp_check.py, tests/native/Tests for the TOTP code and the lock

First the part that has nothing to do with the logon.

TOTP

TOTP (RFC 6238) creates a 6-digit code from a secret and the current time. It works in three steps:

  1. Take the unix time and divide it by 30. This is a counter that changes every 30 seconds. The phone and the PC get the same counter, as long as the clocks are correct.
  2. Calculate HMAC-SHA1(secret, counter). The result has 20 bytes. Only someone with the secret can calculate it.
  3. Make 6 digits out of the 20 bytes. This is called dynamic truncation (RFC 4226, section 5.3). The last 4 bits of the hash are an offset. From there you read 4 bytes, remove the highest bit so the number is not negative, and take the result modulo 1'000'000.

For the HMAC I use CNG (bcrypt) from Windows, so no external libraries are needed. The whole thing has about 160 lines.

totp.h:

C++
#pragma once
#include <windows.h>
#include <cstdint>
#include <string>
#include <vector>

namespace tac
{
    bool        Base32Decode(const std::string& in, std::vector<BYTE>& out);
    std::string Base32Encode(const BYTE* data, size_t len);

    // RFC 6238 code for a given unix time. Returns false if the HMAC fails.
    bool TotpAt(const std::vector<BYTE>& key, uint64_t unixTime,
                uint32_t step, int digits, uint32_t& code);

    // Accepts the time step of `now` and +/- window steps around it, but only
    // steps greater than lastStep (replay protection, 0 = nothing used yet).
    // On success matchedStep is the step the code belongs to.
    bool ValidateTotp(const std::vector<BYTE>& key, const std::wstring& input,
                      uint64_t now, uint64_t lastStep, uint64_t& matchedStep,
                      uint32_t step = 30, int digits = 6, int window = 1);
}

totp.cpp:

C++
#include "totp.h"
#include <bcrypt.h>

#pragma comment(lib, "bcrypt.lib")

namespace tac
{
    static const char kAlphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

    static int Base32Value(char c)
    {
        if (c >= 'A' && c <= 'Z') return c - 'A';
        if (c >= 'a' && c <= 'z') return c - 'a';
        if (c >= '2' && c <= '7') return c - '2' + 26;
        return -1;
    }

    bool Base32Decode(const std::string& in, std::vector<BYTE>& out)
    {
        uint32_t buffer = 0;
        int bits = 0;
        out.clear();

        for (char c : in)
        {
            if (c == '=' || c == ' ')
                continue;
            int v = Base32Value(c);
            if (v < 0)
                return false;

            buffer = (buffer << 5) | static_cast<uint32_t>(v);
            bits += 5;
            if (bits >= 8)
            {
                bits -= 8;
                out.push_back(static_cast<BYTE>(buffer >> bits));
                buffer &= (1u << bits) - 1;   // keep only the bits not consumed yet
            }
        }
        return true;
    }

    std::string Base32Encode(const BYTE* data, size_t len)
    {
        std::string out;
        uint32_t buffer = 0;
        int bits = 0;

        for (size_t i = 0; i < len; ++i)
        {
            buffer = (buffer << 8) | data[i];
            bits += 8;
            while (bits >= 5)
            {
                bits -= 5;
                out += kAlphabet[(buffer >> bits) & 0x1F];
            }
            buffer &= (1u << bits) - 1;
        }
        if (bits > 0)
            out += kAlphabet[(buffer << (5 - bits)) & 0x1F];
        return out;
    }

    static bool HmacSha1(const std::vector<BYTE>& key, const BYTE* msg, ULONG cbMsg, BYTE mac[20])
    {
        BCRYPT_ALG_HANDLE  hAlg  = nullptr;
        BCRYPT_HASH_HANDLE hHash = nullptr;
        bool ok = false;

        if (BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_SHA1_ALGORITHM, nullptr,
                                        BCRYPT_ALG_HANDLE_HMAC_FLAG) != 0)
            return false;

        if (BCryptCreateHash(hAlg, &hHash, nullptr, 0, const_cast<PUCHAR>(key.data()),
                             static_cast<ULONG>(key.size()), 0) == 0)
        {
            ok = BCryptHashData(hHash, const_cast<PUCHAR>(msg), cbMsg, 0) == 0 &&
                 BCryptFinishHash(hHash, mac, 20, 0) == 0;
            BCryptDestroyHash(hHash);
        }
        BCryptCloseAlgorithmProvider(hAlg, 0);
        return ok;
    }

    bool TotpAt(const std::vector<BYTE>& key, uint64_t unixTime,
                uint32_t step, int digits, uint32_t& code)
    {
        // The counter is the number of time steps since 1970, as 8 bytes big-endian.
        uint64_t counter = unixTime / step;
        BYTE msg[8];
        for (int i = 7; i >= 0; --i)
        {
            msg[i] = static_cast<BYTE>(counter & 0xFF);
            counter >>= 8;
        }

        BYTE mac[20];
        if (!HmacSha1(key, msg, sizeof(msg), mac))
            return false;

        // Dynamic truncation (RFC 4226, 5.3): the low nibble of the last byte
        // selects 4 bytes; the top bit is dropped to avoid signed values.
        int offset = mac[19] & 0x0F;
        uint32_t bin = ((mac[offset]     & 0x7Fu) << 24) |
                       ((mac[offset + 1] & 0xFFu) << 16) |
                       ((mac[offset + 2] & 0xFFu) <<  8) |
                        (mac[offset + 3] & 0xFFu);

        uint32_t mod = 1;
        for (int i = 0; i < digits; ++i)
            mod *= 10;
        code = bin % mod;
        return true;
    }

    bool ValidateTotp(const std::vector<BYTE>& key, const std::wstring& input,
                      uint64_t now, uint64_t lastStep, uint64_t& matchedStep,
                      uint32_t step, int digits, int window)
    {
        matchedStep = 0;

        // Exactly `digits` ASCII digits. wcstoul would also accept "12abc" or "0".
        if (input.size() != static_cast<size_t>(digits))
            return false;
        uint32_t entered = 0;
        for (wchar_t c : input)
        {
            if (c < L'0' || c > L'9')
                return false;
            entered = entered * 10 + static_cast<uint32_t>(c - L'0');
        }

        const uint64_t current = now / step;
        for (int w = -window; w <= window; ++w)
        {
            if (w < 0 && current < static_cast<uint64_t>(-w))
                continue;
            const uint64_t counter = current + static_cast<int64_t>(w);

            // "<=" and not "==": a step at or before the last used one is never
            // accepted again. This also stops an old code after the clock was
            // turned back.
            if (counter <= lastStep)
                continue;

            uint32_t expected = 0;
            if (TotpAt(key, counter * step, step, digits, expected) && expected == entered)
            {
                matchedStep = counter;
                return true;
            }
        }
        return false;
    }
}

A few details in the code:

  • ValidateTotp only accepts exactly 6 digits. A function like wcstoul would also accept "12abc" or just "0", which would match the code 000000.
  • TotpAt returns bool and the code via an out parameter. My first version returned a special value on error. The problem is that a user could type exactly this value, and then a failed HMAC would look like a correct code. So don't use a normal value as an error value.
  • The Base32 buffers are unsigned and masked. Without the mask a signed int overflows with a 32 character secret and that is undefined behavior.

The code accepts one step before and after the current time. This is for clocks that are not exactly in sync. In total a code is valid for about 90 seconds.

A code works only once. 90 seconds is a lot of time. Someone who looks over your shoulder can type the same code on another machine, or on the same machine right after you locked it. That's why ValidateTotp gets lastStep: the time step of the last code that was accepted for this user. Every step up to and including lastStep is skipped.

The check has to be <= and not ==. With == only the exact same code would be blocked. The code of the step before is still inside the window and would work. And there is a nastier attack: someone writes down your code, later turns the clock back in the BIOS, pulls the network cable so Windows can't sync the time, boots and types the old code. TOTP trusts whatever time the machine says. With <= this doesn't work anymore, because the old step is smaller than the one that was already used.

ValidateTotp also returns the step that matched (matchedStep). This is the value that gets stored, not the current time. If the clocks are a bit off and the code of the next step is used, exactly this step is stored.

now is a parameter now instead of calling time() inside the function. This makes the function testable. I can check any point in time without touching the clock.

Where lastStep is stored and who writes it comes in the chapter about rate limiting.

Test the TOTP code first

Debugging crypto on the logon screen is no fun. So I tested the algorithm first. This Python script does exactly the same as the C++ code and checks it against the official test values from RFC 6238:

Python
import hmac, hashlib, struct, sys

BASE32="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"

# --- faithful port of my C++ Base32Encode ---
def b32enc(data):
    out=""; buf=0; bits=0
    for b in data:
        buf=(buf<<8)|b; bits+=8
        while bits>=5:
            bits-=5; out+=BASE32[(buf>>bits)&0x1F]
        buf &= (1<<bits)-1
    if bits>0: out+=BASE32[(buf<<(5-bits))&0x1F]
    return out

# --- faithful port of my C++ Base32Decode ---
def b32dec(s):
    def val(c):
        if 'A'<=c<='Z': return ord(c)-65
        if 'a'<=c<='z': return ord(c)-97
        if '2'<=c<='7': return ord(c)-ord('2')+26
        return -1
    buf=0; bits=0; out=bytearray()
    for c in s:
        if c in '= ': continue
        v=val(c)
        if v<0: return None
        buf=(buf<<5)|v; bits+=5
        if bits>=8:
            bits-=8; out.append((buf>>bits)&0xFF); buf&=(1<<bits)-1
    return bytes(out)

# --- faithful port of my C++ TotpAt ---
def totp_at(key, unixtime, step=30, digits=6):
    counter = unixtime//step
    msg = struct.pack(">Q", counter)          # big-endian 8 bytes == my loop
    h = hmac.new(key, msg, hashlib.sha1).digest()
    off = h[19] & 0x0F
    binv = ((h[off]&0x7F)<<24)|((h[off+1]&0xFF)<<16)|((h[off+2]&0xFF)<<8)|(h[off+3]&0xFF)
    mod = 10**digits
    return binv % mod

seed = b"12345678901234567890"  # RFC 6238 appendix B (SHA1)
vectors = {59:94287082, 1111111109:7081804, 1111111111:14050471,
           1234567890:89005924, 2000000000:69279037, 20000000000:65353130}

print("== RFC 6238 (8-digit) ==")
ok_all=True
for t,exp in vectors.items():
    got8 = totp_at(seed, t, digits=8)
    got6 = totp_at(seed, t, digits=6)
    ok = (got8==exp)
    ok_all &= ok
    print(f"T={t:<12} got8={got8:08d} exp={exp:08d} {'OK' if ok else 'FAIL'}   (6-digit={got6:06d})")

print("\n== Base32 round-trip ==")
enc=b32enc(seed)
dec=b32dec(enc)
print("encode(seed)=",enc)
print("decode==seed:", dec==seed)

# Known: base32 of ASCII "12345678901234567890" is GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
print("matches known GEZD... :", enc=="GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
ok_all &= dec==seed and enc=="GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"

# --- faithful port of my C++ ValidateTotp (with replay protection) ---
def validate(key, code, now, last_step, step=30, digits=6, window=1):
    if len(code)!=digits or not all('0'<=c<='9' for c in code): return None
    current = now//step
    for w in range(-window, window+1):
        if w<0 and current < -w: continue
        counter = current + w
        if counter <= last_step: continue          # "<=", not "=="
        if totp_at(key, counter*step, step, digits) == int(code): return counter
    return None

print("\n== Replay protection ==")
T=1800000000
c=f"{totp_at(seed,T):06d}"
s1=validate(seed,c,T,0)
checks = {
    "fresh code accepted":             s1==T//30,
    "same code rejected":              validate(seed,c,T+5,s1) is None,
    "previous step rejected":          validate(seed,f"{totp_at(seed,T-30):06d}",T,s1) is None,
    "next step accepted":              validate(seed,f"{totp_at(seed,T+30):06d}",T,s1)==T//30+1,
    "old code after clock turned back":validate(seed,f"{totp_at(seed,T-3600):06d}",T-3600,s1) is None,
    "'12abc' rejected":                validate(seed,"12abc",T,0) is None,
}
for k,v in checks.items(): print(f"{k:<34} {'OK' if v else 'FAIL'}")
ok_all &= all(checks.values())

print("\nALL CHECKS PASS:", ok_all)

# CI runs this script on its own; without a failing exit code it could never go red.
sys.exit(0 if ok_all else 1)

All six values have to be correct (94287082, 07081804, 14050471, 89005924, 69279037, 65353130). The Base32 value of the test seed has to be GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ.

The second part checks the replay protection with a port of ValidateTotp. A fresh code works, and so does the code of the next step. The same code a second time, the code of the step before and an old code after turning the clock back don't. At the end it has to say ALL CHECKS PASS: True.

The script only shows that I understood the algorithm. It doesn't show that the C++ code does the same. For that the repo now has native tests in tests/native/. They compile the real totp.cpp and verify.cpp on Linux, with OpenSSL instead of CNG and a store in memory instead of the registry. Then they check the RFC values, the lock, the replay protection and that nothing is accepted if the state can't be saved: make -C tests/native.

And the Python script now ends with an error code when a check fails. My first version only printed False, and the GitHub Actions workflow stayed green.

Secret store

store.h:

C++
#pragma once
#include <windows.h>
#include <cstdint>
#include <string>
#include <vector>

namespace tac
{
    // "PC\Alice", ".\alice" and "alice" all become "alice". Case folding is
    // locale-invariant, so non-ASCII names fold the same way everywhere.
    std::wstring NormalizeUser(const std::wstring& user);

    // The same, for code inside lsass: `user` is counted (cch characters, it
    // may also end early at a NUL), the result goes into a caller buffer, and
    // nothing allocates. Returns false for an empty name or one that does not
    // fit. Lives in store_index.cpp.
    bool NormalizeUserInto(const WCHAR* user, SIZE_T cch, WCHAR* out, SIZE_T outCap);

    // Looks up COMPUTERNAME\name and returns the SID string ("S-1-5-21-...")
    // if it is a local user account. Everything else returns false.
    bool ResolveLocalUserSid(const std::wstring& user, std::wstring& sid);

    // The last sub-authority of a SID string, the RID ("...-1001" -> 1001).
    // Returns false for anything that is not a well-formed SID string.
    bool RidFromSidString(const std::wstring& sid, DWORD& rid);

    // Is this a syntactically valid SID string ("S-1-...")? Lets enroll accept
    // a SID where a name no longer resolves.
    bool IsSidString(const std::wstring& sid);

    // The secret is stored per SID, not per name. A renamed or re-created
    // account is a different account. StoreSecret also mirrors two enrollment
    // indexes for code that runs inside lsass: one keyed by RID (see
    // EnrollmentByRid) and one keyed by name (see EnrollmentByName).
    bool StoreSecret(const std::wstring& sid, const std::string& base32Secret);
    bool LoadSecretKey(const std::wstring& sid, std::vector<BYTE>& key);

    // Hot-path enrollment checks for code that runs INSIDE lsass. Each is a
    // single registry read: no DPAPI decrypt and no account-name or SID lookup,
    // so nothing calls back into LSA and the logon path cannot deadlock.
    // Neither allocates: a caller in lsass must not risk std::bad_alloc.
    //
    // Three outcomes, because a caller that VETOES must not read a failed read
    // as "not enrolled" - that is the one wrong answer that lets an enrolled
    // account through. A missing index key is No (nothing indexed on this
    // machine at all); any other read error is Unknown.
    enum class Enrollment { No, Yes, Unknown };

    // EnrollmentByRid is the one to use whenever the caller has the account's
    // RID from the SAM (the MSV1_0 sub-authentication filter gets it in
    // USER_ALL_INFORMATION). A RID survives a rename and the SAM never hands
    // out a RID twice, so this index can neither miss a renamed account nor
    // match a different, re-created one.
    Enrollment EnrollmentByRid(DWORD rid);

    // IsEnrolledByRid is EnrollmentByRid(rid) == Yes, so it reads every failure
    // as "not enrolled". Only for callers that do not veto.
    bool IsEnrolledByRid(DWORD rid);

    // EnrollmentByName is only for callers that have nothing but the name a
    // client typed (the authentication package). `name` is counted, not
    // null-terminated, and is normalized into a fixed buffer here.
    //
    // It is weaker than the RID check and fails OPEN in one case: after an
    // enrolled account is renamed, the index still holds the old name, so the
    // new name reads as "not enrolled" until `enroll /reindex` runs. A
    // re-created account with an old name reads as enrolled (more
    // restrictive). Prefer EnrollmentByRid wherever the RID is available.
    Enrollment EnrollmentByName(const WCHAR* name, SIZE_T cch);

    // Removes an enrollment completely: the DPAPI secret, the state and the RID
    // index entry under the SID, and the name-keyed index entry. Both halves are
    // removed independently on purpose. The name entry has to go even when the
    // Windows account was already deleted and the SID can no longer be resolved -
    // otherwise that name stays "enrolled" forever. (A RID entry of a deleted
    // account is harmless: the SAM never reuses the RID.)
    // An empty user or sid simply skips that half. A value that is not there
    // counts as removed.
    bool RemoveEnrollment(const std::wstring& user, const std::wstring& sid);

    // Is at least one account enrolled? Counts only stored secrets: REG_BINARY
    // values whose name is a SID string. A stray value of another type or name
    // does not count, because the credential provider's filter hides every
    // other sign-in option as soon as this says yes - and it must not do that
    // when nothing can actually authenticate.
    // Returns false if the store exists but cannot be read; a store that does
    // not exist yet is "readable, nothing enrolled".
    bool QueryAnyEnrollment(bool& any);

    // One enrolled account, for `enroll /list`.
    struct EnrollmentEntry
    {
        std::wstring sid;
        std::wstring name;      // empty when the SID no longer resolves
        DWORD        rid;
        bool         ridIndexed;
        bool         nameIndexed;
    };

    // Every stored secret with its index state. Returns false if the store
    // cannot be read; an empty store is "readable, nothing enrolled".
    bool ListEnrollments(std::vector<EnrollmentEntry>& out);

    // Removes everything this project keeps in the registry: secrets, state
    // and both indexes. For a full uninstall. A tree that is not there counts
    // as removed.
    bool PurgeAllEnrollments();

    // Re-applies the "SYSTEM and Administrators only" ACL to the store keys
    // that already exist. New keys get it when they are created; this repairs
    // keys left over from an older version with a weaker ACL. Called by
    // enroll.exe, never from the logon path - it needs WRITE_DAC and writes to
    // the hive, and neither belongs in a logon.
    bool EnsureStoreAcls();

    // Rebuilds the RID and name indexes from the stored secrets. For accounts
    // that were enrolled before the RID index existed, and after a rename.
    // Stale name entries of renamed accounts are left alone (they only make the
    // name check stricter). Returns the number of enrollments indexed, -1 if
    // the enrollments cannot be read or a RID entry cannot be written.
    int ReindexEnrollments();

    // Per-account state for replay protection and rate limiting.
    struct UserState
    {
        uint64_t lastStep;      // last accepted TOTP time step, 0 = none yet
        uint32_t failures;      // wrong codes in a row
        uint32_t reserved;
        uint64_t lockedUntil;   // unix time, 0 = not locked
    };
    static_assert(sizeof(UserState) == 24, "UserState is stored as-is in the registry");

    // A missing value is a fresh state (all zero). A broken value is an error.
    // A read-modify-write of the state must hold a StateLock (statelock.h).
    bool LoadState(const std::wstring& sid, UserState& state);
    bool SaveState(const std::wstring& sid, const UserState& state);
    bool DeleteState(const std::wstring& sid);
}

store.cpp:

C++
#include "store.h"
#include "storekeys.h"
#include "totp.h"
#include <wincrypt.h>
#include <sddl.h>

#pragma comment(lib, "crypt32.lib")
#pragma comment(lib, "advapi32.lib")

// The index reads that run inside lsass (EnrollmentByRid, EnrollmentByName,
// NormalizeUserInto) live in store_index.cpp, so the LSA DLLs do not have to
// link DPAPI and TOTP code they never use. Everything here runs in LogonUI or
// enroll.exe only.

namespace tac
{
    using keys::kSecrets;
    using keys::kState;
    using keys::kNames;
    using keys::kRids;
    using keys::kMaxNameCch;

    // SYSTEM and Administrators only. "P" blocks the inherited ACL of
    // HKLM\SOFTWARE, which would let every user read the key.
    static const wchar_t kKeySddl[] = L"D:P(A;OICI;KA;;;SY)(A;OICI;KA;;;BA)";

    std::wstring NormalizeUser(const std::wstring& user)
    {
        WCHAR buf[kMaxNameCch];
        if (!NormalizeUserInto(user.c_str(), user.size(), buf, ARRAYSIZE(buf)))
            return std::wstring();
        return std::wstring(buf);
    }

    static bool Protect(const std::string& plain, std::vector<BYTE>& blob)
    {
        DATA_BLOB in  = { static_cast<DWORD>(plain.size()),
                          reinterpret_cast<BYTE*>(const_cast<char*>(plain.data())) };
        DATA_BLOB out = {};

        // Machine scope, so LogonUI (SYSTEM) can decrypt what an admin encrypted.
        if (!CryptProtectData(&in, L"tac-2fa", nullptr, nullptr, nullptr,
                              CRYPTPROTECT_LOCAL_MACHINE | CRYPTPROTECT_UI_FORBIDDEN, &out))
            return false;

        blob.assign(out.pbData, out.pbData + out.cbData);
        LocalFree(out.pbData);
        return true;
    }

    static bool Unprotect(const std::vector<BYTE>& blob, std::string& plain)
    {
        DATA_BLOB in  = { static_cast<DWORD>(blob.size()), const_cast<BYTE*>(blob.data()) };
        DATA_BLOB out = {};

        if (!CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr,
                                CRYPTPROTECT_UI_FORBIDDEN, &out))
            return false;

        plain.assign(reinterpret_cast<char*>(out.pbData), out.cbData);
        SecureZeroMemory(out.pbData, out.cbData);
        LocalFree(out.pbData);
        return true;
    }

    // Resolves the name the same way LSA will: as COMPUTERNAME\name against the
    // local SAM. Only a real user account counts, not a group or an alias.
    bool ResolveLocalUserSid(const std::wstring& user, std::wstring& sid)
    {
        sid.clear();
        std::wstring name = NormalizeUser(user);
        if (name.empty())
            return false;

        WCHAR computer[MAX_COMPUTERNAME_LENGTH + 1] = {};
        DWORD cchComputer = ARRAYSIZE(computer);
        if (!GetComputerNameW(computer, &cchComputer))
            return false;

        std::wstring qualified = std::wstring(computer) + L"\\" + name;
        BYTE sidBuf[SECURITY_MAX_SID_SIZE];
        DWORD cbSid = sizeof(sidBuf);
        WCHAR domain[256] = {};
        DWORD cchDomain = ARRAYSIZE(domain);
        SID_NAME_USE use;
        if (!LookupAccountNameW(nullptr, qualified.c_str(), sidBuf, &cbSid,
                                domain, &cchDomain, &use))
            return false;

        // The account has to live in this computer's SAM, not in a domain.
        if (use != SidTypeUser || _wcsicmp(domain, computer) != 0)
            return false;

        PWSTR pszSid = nullptr;
        if (!ConvertSidToStringSidW(sidBuf, &pszSid))
            return false;
        sid = pszSid;
        LocalFree(pszSid);
        return true;
    }

    // Only SIDs are used as value names. This keeps odd input out of the registry.
    bool IsSidString(const std::wstring& sid)
    {
        return sid.size() > 4 && sid.compare(0, 4, L"S-1-") == 0;
    }

    bool RidFromSidString(const std::wstring& sid, DWORD& rid)
    {
        rid = 0;
        if (!IsSidString(sid))
            return false;
        size_t dash = sid.find_last_of(L'-');
        if (dash == std::wstring::npos || dash + 1 >= sid.size() || sid.size() - dash - 1 > 10)
            return false;

        uint64_t value = 0;
        for (size_t i = dash + 1; i < sid.size(); ++i)
        {
            if (sid[i] < L'0' || sid[i] > L'9')
                return false;
            value = value * 10 + static_cast<uint64_t>(sid[i] - L'0');
        }
        if (value == 0 || value > 0xFFFFFFFFull)
            return false;
        rid = static_cast<DWORD>(value);
        return true;
    }

    // SID string -> normalized local account name. Used only at enroll time (a
    // console app), never on the LSA hot path.
    static bool NameFromSid(const std::wstring& sidStr, std::wstring& name)
    {
        name.clear();
        PSID psid = nullptr;
        if (!ConvertStringSidToSidW(sidStr.c_str(), &psid))
            return false;

        WCHAR n[256] = {}; DWORD cn = ARRAYSIZE(n);
        WCHAR d[256] = {}; DWORD cd = ARRAYSIZE(d);
        SID_NAME_USE use;
        BOOL ok = LookupAccountSidW(nullptr, psid, n, &cn, d, &cd, &use);
        LocalFree(psid);
        if (!ok)
            return false;

        name = NormalizeUser(n);
        return !name.empty();
    }

    // Creates or opens a key below HKLM with our ACL. The security attributes
    // only apply when the key is CREATED; an existing key keeps whatever ACL it
    // has. Repairing one needs WRITE_DAC and a hive write, neither of which
    // belongs on the logon path (SaveState runs there on every code attempt),
    // so that is EnsureStoreAcls' job and enroll.exe calls it.
    static LSTATUS OpenProtectedKey(const wchar_t* path, HKEY* phKey)
    {
        PSECURITY_DESCRIPTOR psd = nullptr;
        if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(kKeySddl, SDDL_REVISION_1,
                                                                  &psd, nullptr))
            return static_cast<LSTATUS>(GetLastError());

        SECURITY_ATTRIBUTES sa = { sizeof(sa), psd, FALSE };
        LSTATUS status = RegCreateKeyExW(HKEY_LOCAL_MACHINE, path, 0, nullptr, 0,
                                         KEY_SET_VALUE | KEY_QUERY_VALUE,
                                         &sa, phKey, nullptr);
        LocalFree(psd);
        return status;
    }

    bool EnsureStoreAcls()
    {
        PSECURITY_DESCRIPTOR psd = nullptr;
        if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(kKeySddl, SDDL_REVISION_1,
                                                                  &psd, nullptr))
            return false;

        bool ok = true;
        const wchar_t* paths[] = { kSecrets, kState, kNames, kRids };
        for (const wchar_t* path : paths)
        {
            HKEY hKey = nullptr;
            LSTATUS status = RegOpenKeyExW(HKEY_LOCAL_MACHINE, path, 0, WRITE_DAC, &hKey);
            if (status == ERROR_FILE_NOT_FOUND)
                continue;                   // not there yet, nothing to repair
            if (status != ERROR_SUCCESS)
            {
                ok = false;
                continue;
            }
            if (RegSetKeySecurity(hKey, DACL_SECURITY_INFORMATION, psd) != ERROR_SUCCESS)
                ok = false;
            RegCloseKey(hKey);
        }

        LocalFree(psd);
        return ok;
    }

    // Deletes one value and treats "was not there" as done.
    static bool DeleteValue(const wchar_t* path, const std::wstring& value)
    {
        HKEY hKey = nullptr;
        LSTATUS status = RegOpenKeyExW(HKEY_LOCAL_MACHINE, path, 0, KEY_SET_VALUE, &hKey);
        if (status == ERROR_FILE_NOT_FOUND)
            return true;
        if (status != ERROR_SUCCESS)
            return false;

        status = RegDeleteValueW(hKey, value.c_str());
        RegCloseKey(hKey);
        return status == ERROR_SUCCESS || status == ERROR_FILE_NOT_FOUND;
    }

    // Writes a presence flag (REG_DWORD 1) under one of the index keys.
    static bool SetFlag(const wchar_t* path, const std::wstring& value)
    {
        HKEY hKey = nullptr;
        if (OpenProtectedKey(path, &hKey) != ERROR_SUCCESS)
            return false;
        DWORD one = 1;
        bool ok = RegSetValueExW(hKey, value.c_str(), 0, REG_DWORD,
                                 reinterpret_cast<const BYTE*>(&one), sizeof(one)) == ERROR_SUCCESS;
        RegCloseKey(hKey);
        return ok;
    }

    // Writes the RID index and, if the SID still resolves to a name, the name
    // index for one enrolled SID.
    static void WriteIndexes(const std::wstring& sid, bool& ridOk, bool& nameOk)
    {
        ridOk = nameOk = false;

        DWORD rid = 0;
        if (RidFromSidString(sid, rid))
            ridOk = SetFlag(kRids, std::to_wstring(rid));

        std::wstring name;
        if (NameFromSid(sid, name))
            nameOk = SetFlag(kNames, name);
    }

    // Every REG_BINARY value under a SID name in the secret store. Shared by
    // QueryAnyEnrollment, ListEnrollments and ReindexEnrollments so all three
    // agree on what counts as an enrollment.
    static bool CollectEnrolledSids(std::vector<std::wstring>& sids)
    {
        sids.clear();

        HKEY hKey = nullptr;
        LSTATUS status = RegOpenKeyExW(HKEY_LOCAL_MACHINE, kSecrets, 0, KEY_QUERY_VALUE, &hKey);
        if (status == ERROR_FILE_NOT_FOUND)
            return true;                    // nothing enrolled yet
        if (status != ERROR_SUCCESS)
            return false;

        for (DWORD i = 0;; ++i)
        {
            WCHAR name[256];
            DWORD cch = ARRAYSIZE(name);
            DWORD type = 0;
            status = RegEnumValueW(hKey, i, name, &cch, nullptr, &type, nullptr, nullptr);
            if (status == ERROR_NO_MORE_ITEMS)
                break;
            if (status == ERROR_MORE_DATA)
                continue;                   // not one of ours, SIDs are short
            if (status != ERROR_SUCCESS)
            {
                RegCloseKey(hKey);
                return false;
            }
            if (type == REG_BINARY && IsSidString(std::wstring(name, cch)))
                sids.emplace_back(name, cch);
        }

        RegCloseKey(hKey);
        return true;
    }

    bool StoreSecret(const std::wstring& sid, const std::string& base32Secret)
    {
        if (!IsSidString(sid))
            return false;

        std::vector<BYTE> blob;
        if (!Protect(base32Secret, blob))
            return false;

        // The indexes the LSA hot path reads come first. If they cannot be
        // written, nothing else is touched, so a re-enrollment that fails
        // leaves the old enrollment working. If the secret then fails, the
        // index entries stay behind: that only makes the LSA packages refuse
        // MORE (network logon of an account without a secret), never less,
        // and `enroll /remove` clears them.
        bool ridOk = false, nameOk = false;
        WriteIndexes(sid, ridOk, nameOk);
        if (!ridOk || !nameOk)
            return false;

        HKEY hKey = nullptr;
        LSTATUS status = OpenProtectedKey(kSecrets, &hKey);
        if (status == ERROR_SUCCESS)
        {
            status = RegSetValueExW(hKey, sid.c_str(), 0, REG_BINARY,
                                    blob.data(), static_cast<DWORD>(blob.size()));
            RegCloseKey(hKey);
        }
        return status == ERROR_SUCCESS;
    }

    bool LoadSecretKey(const std::wstring& sid, std::vector<BYTE>& key)
    {
        key.clear();
        if (!IsSidString(sid))
            return false;

        HKEY hKey = nullptr;
        if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, kSecrets, 0, KEY_QUERY_VALUE, &hKey) != ERROR_SUCCESS)
            return false;

        DWORD type = 0;
        DWORD cb = 0;
        std::vector<BYTE> blob;
        LSTATUS status = RegQueryValueExW(hKey, sid.c_str(), nullptr, &type, nullptr, &cb);
        if (status == ERROR_SUCCESS && type == REG_BINARY && cb > 0)
        {
            blob.resize(cb);
            status = RegQueryValueExW(hKey, sid.c_str(), nullptr, &type, blob.data(), &cb);
        }
        RegCloseKey(hKey);
        if (status != ERROR_SUCCESS || blob.empty())
            return false;

        std::string base32;
        if (!Unprotect(blob, base32))
            return false;

        bool ok = Base32Decode(base32, key);
        SecureZeroMemory(&base32[0], base32.size());

        // An empty or very short key would make the code predictable. Our
        // secrets have 20 bytes; anything under 16 counts as broken.
        if (!ok || key.size() < 16)
        {
            if (!key.empty())
                SecureZeroMemory(key.data(), key.size());
            key.clear();
            return false;
        }
        return true;
    }

    bool RemoveEnrollment(const std::wstring& user, const std::wstring& sid)
    {
        bool ok = true;

        // The name-keyed index first: it is the half that can still be found
        // when the account no longer exists. The RID entry of a deleted
        // account is harmless, the SAM never hands that RID out again.
        std::wstring typed = NormalizeUser(user);
        if (!typed.empty())
            ok = DeleteValue(kNames, typed) && ok;

        if (IsSidString(sid))
        {
            // StoreSecret keys the index by the name it got from NameFromSid,
            // not by whatever was typed. Those are normally the same, but
            // LookupAccountName accepts spellings the SAM does not store, so
            // remove the canonical one as well. Deleting a value twice is free.
            std::wstring canonical;
            if (NameFromSid(sid, canonical) && !canonical.empty() && canonical != typed)
                ok = DeleteValue(kNames, canonical) && ok;

            DWORD rid = 0;
            if (RidFromSidString(sid, rid))
                ok = DeleteValue(kRids, std::to_wstring(rid)) && ok;

            ok = DeleteValue(kSecrets, sid) && ok;
            ok = DeleteValue(kState, sid) && ok;
        }
        return ok;
    }

    bool QueryAnyEnrollment(bool& any)
    {
        any = false;

        // Counts stored secrets, not values: a stray value of another type or
        // name is not an enrollment. The credential provider's filter hides
        // every other sign-in option as soon as this says yes, and it must not
        // do that while nothing on the machine can actually authenticate.
        std::vector<std::wstring> sids;
        if (!CollectEnrolledSids(sids))
            return false;

        any = !sids.empty();
        return true;
    }

    bool ListEnrollments(std::vector<EnrollmentEntry>& out)
    {
        out.clear();

        std::vector<std::wstring> sids;
        if (!CollectEnrolledSids(sids))
            return false;

        for (const std::wstring& sid : sids)
        {
            EnrollmentEntry e;
            e.sid         = sid;
            e.rid         = 0;
            e.ridIndexed  = false;
            e.nameIndexed = false;

            if (RidFromSidString(sid, e.rid))
                e.ridIndexed = EnrollmentByRid(e.rid) == Enrollment::Yes;

            std::wstring name;
            if (NameFromSid(sid, name))
            {
                e.name = name;
                e.nameIndexed = EnrollmentByName(name.c_str(), name.size()) == Enrollment::Yes;
            }

            out.push_back(e);
        }
        return true;
    }

    bool PurgeAllEnrollments()
    {
        // HKLM\SOFTWARE\TheAdminCafe\2FA and everything below it, then the
        // vendor key if nothing else lives there.
        LSTATUS status = RegDeleteTreeW(HKEY_LOCAL_MACHINE, kSecrets);
        if (status != ERROR_SUCCESS && status != ERROR_FILE_NOT_FOUND)
            return false;
        status = RegDeleteKeyW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\TheAdminCafe");
        // Not empty (someone else's data) or already gone: both fine.
        (void)status;
        return true;
    }

    int ReindexEnrollments()
    {
        // Collect first, write afterwards: the index keys are subkeys of the
        // secret store, and enumerating while writing elsewhere is simpler to
        // reason about.
        std::vector<std::wstring> sids;
        if (!CollectEnrolledSids(sids))
            return -1;

        // The RID index is what the sub-authentication filter reads, so a
        // failure there is an error. A missing name is not: the account may
        // have been deleted, and then there is no name to index.
        int count = 0;
        for (const std::wstring& sid : sids)
        {
            bool ridOk = false, nameOk = false;
            WriteIndexes(sid, ridOk, nameOk);
            if (!ridOk)
                return -1;
            ++count;
        }
        return count;
    }

    bool LoadState(const std::wstring& sid, UserState& state)
    {
        ZeroMemory(&state, sizeof(state));
        if (!IsSidString(sid))
            return false;

        HKEY hKey = nullptr;
        LSTATUS status = RegOpenKeyExW(HKEY_LOCAL_MACHINE, kState, 0, KEY_QUERY_VALUE, &hKey);
        if (status == ERROR_FILE_NOT_FOUND)
            return true;                      // nobody has logged on yet
        if (status != ERROR_SUCCESS)
            return false;

        DWORD type = 0;
        DWORD cb = sizeof(state);
        status = RegQueryValueExW(hKey, sid.c_str(), nullptr, &type,
                                  reinterpret_cast<BYTE*>(&state), &cb);
        RegCloseKey(hKey);

        if (status == ERROR_FILE_NOT_FOUND)
        {
            ZeroMemory(&state, sizeof(state));
            return true;                      // this user has not logged on yet
        }
        // Anything else that is not exactly our structure is an error. The
        // caller fails closed, so a broken value cannot reset the counters.
        return status == ERROR_SUCCESS && type == REG_BINARY && cb == sizeof(state);
    }

    bool SaveState(const std::wstring& sid, const UserState& state)
    {
        if (!IsSidString(sid))
            return false;

        HKEY hKey = nullptr;
        LSTATUS status = OpenProtectedKey(kState, &hKey);
        if (status == ERROR_SUCCESS)
        {
            status = RegSetValueExW(hKey, sid.c_str(), 0, REG_BINARY,
                                    reinterpret_cast<const BYTE*>(&state), sizeof(state));
            // No RegFlushKey: it can write out large parts of the hive and
            // blocks the logon until the disk is done, on every attempt. The
            // cost of leaving it out is narrow: a hard reset within seconds of
            // a logon can lose the last state write.
            RegCloseKey(hKey);
        }
        return status == ERROR_SUCCESS;
    }

    bool DeleteState(const std::wstring& sid)
    {
        if (!IsSidString(sid))
            return false;
        return DeleteValue(kState, sid);
    }
}

Since Part 2, the two reads that run inside lsass have their own file. The LSA code links only that file from the store, and store.cpp uses NormalizeUserInto from it. Part 2 explains it. Here it is, so the build of this part is complete.

storekeys.h:

C++
#pragma once
// Registry layout of the 2FA store. Internal to store.cpp and store_index.cpp.
#include <windows.h>

namespace tac
{
    namespace keys
    {
        // Secrets: one REG_BINARY value per SID, DPAPI-protected.
        static const wchar_t kSecrets[] = L"SOFTWARE\\TheAdminCafe\\2FA";
        // Replay and lockout state: one UserState per SID.
        static const wchar_t kState[]   = L"SOFTWARE\\TheAdminCafe\\2FA\\State";
        // Enrollment indexes for the LSA hot path (presence only, no secret).
        static const wchar_t kNames[]   = L"SOFTWARE\\TheAdminCafe\\2FA\\Names";
        static const wchar_t kRids[]    = L"SOFTWARE\\TheAdminCafe\\2FA\\Rids";

        // UNLEN + 1. Nothing the SAM stores comes close.
        static const SIZE_T kMaxNameCch = 257;
    }
}

store_index.cpp:

C++
// The enrollment-index reads that run INSIDE lsass: the sub-authentication
// filter and the authentication package link this file and nothing else from
// the store. No DPAPI, no TOTP, no name or SID lookup, no heap and no CRT of
// our own - see lsasafe.h for why each of those matters in lsass.
//
// Everything that writes the indexes, or needs DPAPI, lives in store.cpp and
// only ever runs in LogonUI or enroll.exe.

#include "store.h"
#include "storekeys.h"

#pragma comment(lib, "advapi32.lib")

namespace tac
{
    bool NormalizeUserInto(const WCHAR* user, SIZE_T cch, WCHAR* out, SIZE_T outCap)
    {
        if (!user || !out || outCap == 0)
            return false;
        out[0] = L'\0';

        // Stop at an embedded NUL: nothing after it belongs to the name.
        SIZE_T len = 0;
        while (len < cch && user[len] != L'\0')
            ++len;

        // Keep only what follows the last backslash.
        SIZE_T start = 0;
        for (SIZE_T i = 0; i < len; ++i)
            if (user[i] == L'\\')
                start = i + 1;

        const SIZE_T bare = len - start;
        if (bare == 0 || bare >= outCap)
            return false;

        // LOCALE_INVARIANT, so "JÖRG" and "jörg" fold the same way on every
        // machine. Plain towlower only ever folded ASCII, which made the name
        // index miss non-ASCII names.
        const int written = LCMapStringW(LOCALE_INVARIANT, LCMAP_LOWERCASE,
                                         user + start, static_cast<int>(bare),
                                         out, static_cast<int>(outCap - 1));
        if (written > 0 && static_cast<SIZE_T>(written) < outCap)
        {
            out[written] = L'\0';
            return true;
        }

        // NLS unavailable or the mapping did not fit: fold ASCII only, which
        // is what every name used to get.
        for (SIZE_T i = 0; i < bare; ++i)
        {
            const WCHAR c = user[start + i];
            out[i] = (c >= L'A' && c <= L'Z') ? static_cast<WCHAR>(c - L'A' + L'a') : c;
        }
        out[bare] = L'\0';
        return true;
    }

    // The RID as the decimal value name of the Rids index, without the CRT.
    // This runs inside lsass, where swprintf_s on an overflow would end the
    // process. A DWORD has at most 10 digits, so out[12] always fits.
    static void RidToValueName(DWORD rid, WCHAR (&out)[12])
    {
        WCHAR digits[10];
        int n = 0;
        do
        {
            digits[n++] = static_cast<WCHAR>(L'0' + rid % 10);
            rid /= 10;
        } while (rid != 0 && n < 10);

        int i = 0;
        while (n > 0)
            out[i++] = digits[--n];
        out[i] = L'\0';                   // i <= 10, so this is in bounds
    }

    // One registry read of an index key, with the three outcomes a vetoing
    // caller needs.
    static Enrollment QueryIndex(const wchar_t* path, const WCHAR* value)
    {
        HKEY hKey = nullptr;
        LSTATUS status = RegOpenKeyExW(HKEY_LOCAL_MACHINE, path, 0, KEY_QUERY_VALUE, &hKey);
        if (status == ERROR_FILE_NOT_FOUND)
            return Enrollment::No;          // no index on this machine at all
        if (status != ERROR_SUCCESS)
            return Enrollment::Unknown;

        status = RegQueryValueExW(hKey, value, nullptr, nullptr, nullptr, nullptr);
        RegCloseKey(hKey);

        if (status == ERROR_SUCCESS)
            return Enrollment::Yes;
        if (status == ERROR_FILE_NOT_FOUND)
            return Enrollment::No;
        // Only a value that is really not there counts as "not enrolled". Any
        // other failure is Unknown: a filter that read it as "not enrolled"
        // would wave an enrolled account through on one unexpected error.
        return Enrollment::Unknown;
    }

    Enrollment EnrollmentByRid(DWORD rid)
    {
        if (rid == 0)
            return Enrollment::No;

        WCHAR value[12];
        RidToValueName(rid, value);
        return QueryIndex(keys::kRids, value);
    }

    bool IsEnrolledByRid(DWORD rid)
    {
        return EnrollmentByRid(rid) == Enrollment::Yes;
    }

    Enrollment EnrollmentByName(const WCHAR* name, SIZE_T cch)
    {
        WCHAR normalized[keys::kMaxNameCch];
        if (!NormalizeUserInto(name, cch, normalized, ARRAYSIZE(normalized)))
            return Enrollment::Unknown;     // unusable name: never claim "No"

        return QueryIndex(keys::kNames, normalized);
    }
}

The secret is encrypted with DPAPI and CRYPTPROTECT_LOCAL_MACHINE. I need this flag, because at logon the code runs in LogonUI as SYSTEM. SYSTEM can only decrypt it, if it was encrypted for the whole machine. But this also means: every process on the machine can decrypt it, also a normal user. DPAPI is no access control here. It only makes sure the secret is not stored in plain text.

So the secret is exactly as safe as every copy of the SOFTWARE hive. The live hive is protected, but there are registry backups, VSS shadow copies and bugs like HiveNightmare (CVE-2021-36934), where normal users could read the hives from the shadow copies. Whoever has a copy of the blob can decrypt it with any process on this machine.

What protects the secret is the ACL on the registry key. The SDDL D:P(A;OICI;KA;;;SY)(A;OICI;KA;;;BA) gives full access to SYSTEM and administrators, nobody else. The P blocks the inheritance from HKLM\SOFTWARE. Without it every user could read the key. So a normal user can't even read the encrypted value. An admin can, but an admin can do much worse things anyway. For a demo this is okay. In production the secret wouldn't sit in a registry hive at all: either as a key in the TPM that never leaves the chip, or on a server that does the check.

Some more details:

  • RegCreateKeyEx only applies the security attributes when it creates the key. An existing key keeps whatever ACL it has. My first version set the ACL again with RegSetKeySecurity every time it opened a key, and SaveState opens one on every code attempt. That's a WRITE_DAC and a hive write on the logon path, for a problem that only an older version of the demo can leave behind. Now enroll.exe repairs the ACL once per run, with EnsureStoreAcls.
  • On decrypt there is no CRYPTPROTECT_LOCAL_MACHINE flag. The scope is stored inside the blob, so DPAPI already knows. But CRYPTPROTECT_UI_FORBIDDEN is mandatory on both sides, because no window may ever pop up on the secure desktop.
  • OpenProtectedKey is used for the secret, the state and the index keys, so all of them get the same ACL when they are created.
  • NormalizeUser folds the name with LCMapStringW and LOCALE_INVARIANT. My first version used towlower, which only folds ASCII, so JÖRG and jörg were two different names for the index.
  • The value name is the SID of the account (S-1-5-21-...-1001), not the username. More about this below. IsSidString makes sure that only SIDs end up as value names.
  • LoadState is strict. A missing value is a user who has never logged on, that's fine. A value that exists but has the wrong size or type is an error, and the logon is refused. The other way around a broken value would reset the counters to zero.
  • SaveState doesn't call RegFlushKey anymore. My first version did, so the used time step would survive a hard reset right after a logon. But RegFlushKey can write big parts of the hive to disk, and the logon waits until it's done. On every attempt. What it protected was a window of a few seconds after a logon. Someone who pulls the power in exactly that moment is in front of the machine anyway.
  • StoreSecret also writes two small index entries, under Rids and Names. They are only a marker, no secret. They are for the LSA code in Part 2, and that's where I explain them. They are written before the secret. If that fails, nothing else is changed and an existing enrollment keeps working. The code that reads them is in store_index.cpp, below.
  • QueryAnyEnrollment is for the filter. It only counts real secrets: REG_BINARY values with a SID as name. ListEnrollments is for enroll.exe /list, and PurgeAllEnrollments for a full uninstall. They all come later in this article.

Why the SID and not the name

My first version stored the secret under the lowercased username. That works until you change something in the user management:

  • You delete alice and later create a new alice for somebody else. The new account gets the old secret, and the old phone logs into it.
  • You rename alice to bob. Now bob has no secret and is locked out. If you create a new alice, she gets the secret of bob.

Windows itself doesn't care about names. An account is its SID. A new account with the same name gets a new SID, a renamed account keeps its SID. So the secret belongs to the SID.

ResolveLocalUserSid does the lookup with LookupAccountNameW and COMPUTERNAME\name. This is exactly the name that goes to LSA later. And it checks two things: the result must be a user (SidTypeUser), not a group or an alias. And the domain in the answer must be this computer. So the lookup can never end up in a domain.

If you used an older version of this demo: the old entries are stored under the name, and the provider doesn't find them anymore. Enroll the users again and delete the old values.

Enrollment tool

Before a user can log in, they need a secret. For this I wrote a small console tool that has to be run as admin.

enroll.cpp:

C++
#include "totp.h"
#include "store.h"
#include "statelock.h"
#include <bcrypt.h>
#include <sddl.h>
#include <conio.h>
#include <cctype>
#include <cstdio>
#include <ctime>
#include <string>
#include <vector>

#pragma comment(lib, "bcrypt.lib")
#pragma comment(lib, "advapi32.lib")

// The otpauth label is UTF-8 and percent-encoded, so names like "jürg" survive.
static std::string UrlEncode(const std::wstring& text)
{
    int cb = WideCharToMultiByte(CP_UTF8, 0, text.c_str(), -1, nullptr, 0, nullptr, nullptr);
    std::string utf8(cb > 1 ? cb - 1 : 0, '\0');
    if (cb > 1)
        WideCharToMultiByte(CP_UTF8, 0, text.c_str(), -1, &utf8[0], cb, nullptr, nullptr);

    static const char hex[] = "0123456789ABCDEF";
    std::string out;
    for (unsigned char c : utf8)
    {
        if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~')
            out += static_cast<char>(c);
        else
        {
            out += '%';
            out += hex[c >> 4];
            out += hex[c & 0x0F];
        }
    }
    return out;
}

// Resets the lock of an enrolled user. The last used time step stays, so an
// unlock never makes an old code valid again.
static int Unlock(const std::wstring& user, const std::wstring& sid)
{
    tac::StateLock lock;
    if (!lock.Held())
    {
        wprintf(L"Could not take the state lock. Run this elevated, from the install folder.\n");
        return 1;
    }

    tac::UserState state;
    if (!tac::LoadState(sid, state))
    {
        wprintf(L"Could not read the state of '%s'. Run this from an elevated prompt.\n", user.c_str());
        return 1;
    }

    uint64_t now = static_cast<uint64_t>(time(nullptr));
    wprintf(L"'%s': %u wrong code(s) in a row, %s.\n", user.c_str(), state.failures,
            state.lockedUntil > now ? L"locked" : L"not locked");

    state.failures    = 0;
    state.lockedUntil = 0;
    if (!tac::SaveState(sid, state))
    {
        wprintf(L"Could not reset the state. Run this from an elevated prompt.\n");
        return 1;
    }
    wprintf(L"Unlocked.\n");
    return 0;
}

// Removes an enrollment: the secret, the state and the RID and name indexes the
// LSA packages read. The account does not have to exist anymore. If it was already
// deleted in Windows we cannot resolve a SID, but we can still drop the name
// entry, and that is the one that would otherwise keep denying network logons.
static int Remove(const std::wstring& user, const std::wstring& sid)
{
    const std::wstring& shown = user.empty() ? sid : user;

    if (sid.empty())
        wprintf(L"'%s' is not a local user account (anymore). Removing the name entry only.\n"
                L"For the secret of a deleted account, use the SID from 'enroll /list'.\n",
                user.c_str());

    if (!tac::RemoveEnrollment(user, sid))
    {
        wprintf(L"Could not remove the enrollment of '%s'. Run this from an elevated prompt.\n",
                shown.c_str());
        return 1;
    }

    wprintf(L"Removed the enrollment of '%s'.\n", shown.c_str());
    wprintf(L"The account can log on with its password alone again, unless you also\n");
    wprintf(L"set the deny rights (deny-noninteractive.ps1).\n");
    return 0;
}

// Lists every stored secret, the account it belongs to and whether the two
// indexes the LSA packages read know about it. A secret whose SID no longer
// resolves belongs to a deleted account: it can never authenticate, but it
// still counts as "enrolled" for the filter, so remove it by SID.
static int List()
{
    std::vector<tac::EnrollmentEntry> entries;
    if (!tac::ListEnrollments(entries))
    {
        wprintf(L"Could not read the enrollments. Run this from an elevated prompt.\n");
        return 1;
    }
    if (entries.empty())
    {
        wprintf(L"No account is enrolled. The filter hides nothing.\n");
        return 0;
    }

    bool orphans = false;
    for (const tac::EnrollmentEntry& e : entries)
    {
        const bool gone = e.name.empty();
        orphans = orphans || gone;
        wprintf(L"  %-20s %s  RID index: %s  name index: %s\n",
                gone ? L"(deleted account)" : e.name.c_str(), e.sid.c_str(),
                e.ridIndexed ? L"yes" : L"NO",
                gone ? L"-" : (e.nameIndexed ? L"yes" : L"NO"));
    }

    if (orphans)
    {
        wprintf(L"\nA deleted account still counts as enrolled, so the filter keeps hiding the\n");
        wprintf(L"other sign-in options. Remove it with: enroll /remove <SID>\n");
    }
    return 0;
}

// Rebuilds the RID and name indexes the LSA packages read, from the stored
// secrets. Needed once after updating from a version without the RID index,
// and after renaming an enrolled account.
static int Reindex()
{
    int count = tac::ReindexEnrollments();
    if (count < 0)
    {
        wprintf(L"Could not read the enrollments or write the index. Run this from an elevated prompt.\n");
        return 1;
    }
    wprintf(L"Indexed %d enrollment(s).\n", count);
    return 0;
}

// Reads one line from the console, at most `max` characters.
static std::wstring ReadLine(size_t max)
{
    wchar_t buf[64] = {};
    if (!fgetws(buf, ARRAYSIZE(buf), stdin))
        return std::wstring();
    std::wstring line(buf);
    SecureZeroMemory(buf, sizeof(buf));
    while (!line.empty() && (line.back() == L'\n' || line.back() == L'\r' || line.back() == L' '))
        line.pop_back();
    if (line.size() > max)
        line.clear();
    return line;
}

// Asks for a code from the authenticator app before anything is stored. A
// secret that was mistyped into the app would otherwise lock the account out
// at the next logon. On success `step` is the time step of the code, so the
// same code cannot be used again at the logon screen.
static bool ConfirmSecret(const std::string& secret, uint64_t& step)
{
    std::vector<BYTE> key;
    if (!tac::Base32Decode(secret, key))
        return false;

    bool ok = false;
    for (int attempt = 1; attempt <= 3 && !ok; ++attempt)
    {
        wprintf(L"Enter the 6-digit code from your authenticator app (attempt %d of 3): ", attempt);
        std::wstring code = ReadLine(16);
        ok = tac::ValidateTotp(key, code, static_cast<uint64_t>(time(nullptr)), 0, step);
        if (!code.empty())
            SecureZeroMemory(&code[0], code.size() * sizeof(WCHAR));
        if (!ok)
            wprintf(L"That code does not match. Check the secret in the app and the clock of both devices.\n");
    }
    SecureZeroMemory(key.data(), key.size());
    return ok;
}

// Deletes every secret, state and index entry. For a full uninstall; the
// provider registration itself is removed by unregister.reg.
static int Purge()
{
    bool any = false;
    if (tac::QueryAnyEnrollment(any) && any)
    {
        wprintf(L"This deletes ALL 2FA secrets and state on this machine. If the filter is\n");
        wprintf(L"still registered, the other sign-in options come back immediately.\n");
        wprintf(L"Continue? [y/N] ");
        int answer = _getwch();
        wprintf(L"\n");
        if (answer != L'y' && answer != L'Y')
        {
            wprintf(L"Aborted.\n");
            return 0;
        }
    }

    if (!tac::PurgeAllEnrollments())
    {
        wprintf(L"Could not delete HKLM\\SOFTWARE\\TheAdminCafe\\2FA. Run this from an elevated prompt.\n");
        return 1;
    }
    wprintf(L"All 2FA data removed.\n");
    return 0;
}

// Parses a SID typed on the command line and returns it in the canonical form
// the store uses as value name ("S-1-5-21-..."). Stricter than the "S-1-"
// prefix check, so a local user who happens to be called "S-1-x" is not
// mistaken for one, and "s-1-5-..." in lower case still finds its entries.
static bool CanonicalSid(const wchar_t* text, std::wstring& sid)
{
    sid.clear();
    PSID psid = nullptr;
    if (!ConvertStringSidToSidW(text, &psid))
        return false;

    PWSTR canonical = nullptr;
    const BOOL ok = ConvertSidToStringSidW(psid, &canonical);
    LocalFree(psid);
    if (!ok)
        return false;

    sid = canonical;
    LocalFree(canonical);
    return true;
}

int wmain(int argc, wchar_t** argv)
{
    if (argc == 2 && _wcsicmp(argv[1], L"/list") == 0)
        return List();
    if (argc == 2 && _wcsicmp(argv[1], L"/purge") == 0)
        return Purge();

    const bool reindex = argc == 2 && _wcsicmp(argv[1], L"/reindex") == 0;
    const bool unlock  = argc == 3 && _wcsicmp(argv[1], L"/unlock") == 0;
    const bool remove  = argc == 3 && _wcsicmp(argv[1], L"/remove") == 0;
    if (!reindex &&
        ((argc != 2 && !unlock && !remove) || (argc == 2 && argv[1][0] == L'/')))
    {
        wprintf(L"Usage: enroll <local username>\n");
        wprintf(L"       enroll /unlock <local username>\n");
        wprintf(L"       enroll /remove <local username | SID>\n");
        wprintf(L"       enroll /list           (enrolled accounts and index state)\n");
        wprintf(L"       enroll /reindex\n");
        wprintf(L"       enroll /purge          (delete all 2FA data)\n");
        return 1;
    }

    // Repair the ACL of store keys an older version created with a weaker one.
    // This used to happen on every state write, i.e. on every logon attempt;
    // it needs WRITE_DAC and a hive write and belongs here instead.
    if (!tac::EnsureStoreAcls())
        wprintf(L"Warning: could not re-apply the ACL to the existing 2FA registry keys.\n");

    if (reindex)
        return Reindex();

    const wchar_t* arg = argv[(unlock || remove) ? 2 : 1];

    // Only names that exist as a local user account on this machine. The
    // secret is stored under the SID of that account.
    std::wstring user = tac::NormalizeUser(arg);
    std::wstring sid;
    if (!tac::ResolveLocalUserSid(user, sid))
    {
        // For /remove an unresolvable name is not fatal: the name-keyed index
        // entry can and should still go. A SID (from `enroll /list`) removes
        // the secret of an account that was already deleted. Everything else
        // needs a live account.
        if (!remove)
        {
            wprintf(L"'%s' is not a local user account on this machine.\n", user.c_str());
            return 1;
        }
        if (CanonicalSid(arg, sid))
            user.clear();
        else
            sid.clear();

        if (user.empty() && sid.empty())
        {
            wprintf(L"'%s' is neither a user name nor a SID.\n", arg);
            return 1;
        }
    }

    if (unlock)
        return Unlock(user, sid);
    if (remove)
        return Remove(user, sid);

    std::vector<BYTE> existing;
    if (tac::LoadSecretKey(sid, existing))
    {
        SecureZeroMemory(existing.data(), existing.size());
        wprintf(L"'%s' is already enrolled. Replace the secret? [y/N] ", user.c_str());
        int answer = _getwch();
        wprintf(L"\n");
        if (answer != L'y' && answer != L'Y')
        {
            wprintf(L"Aborted.\n");
            return 0;
        }
    }

    // 160 bits, the key size RFC 4226 recommends for HMAC-SHA1.
    BYTE raw[20];
    if (BCryptGenRandom(nullptr, raw, sizeof(raw), BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0)
    {
        wprintf(L"Could not generate a random secret.\n");
        return 1;
    }
    std::string secret = tac::Base32Encode(raw, sizeof(raw));
    SecureZeroMemory(raw, sizeof(raw));

    std::string uri = "otpauth://totp/TheAdminCafe:" + UrlEncode(user) +
                      "?secret=" + secret +
                      "&issuer=TheAdminCafe&algorithm=SHA1&digits=6&period=30";

    // Show the secret first and store it only after a code from the app
    // matches. Until then nothing is written, and an existing enrollment
    // keeps working.
    wprintf(L"\nNew secret for '%s' (%s):\n\n", user.c_str(), sid.c_str());
    wprintf(L"  Secret : %S\n", secret.c_str());
    wprintf(L"  URI    : %S\n\n", uri.c_str());
    wprintf(L"Add the secret to your authenticator app. Don't paste it into an online\n");
    wprintf(L"QR code generator.\n\n");
    SecureZeroMemory(&uri[0], uri.size());

    uint64_t confirmedStep = 0;
    if (!ConfirmSecret(secret, confirmedStep))
    {
        wprintf(L"\nNot enrolled: no matching code. Nothing was changed.\n");
        SecureZeroMemory(&secret[0], secret.size());
        return 1;
    }

    // A new secret starts with a clean state: no lock, and the confirmation
    // code counts as used. The two steps are reported separately, because "the
    // secret is stored but the old state is still there" is a different
    // situation from "nothing was written" and needs a different fix.
    if (!tac::StoreSecret(sid, secret))
    {
        wprintf(L"Could not store the secret. Run this from an elevated prompt.\n");
        wprintf(L"An earlier enrollment of '%s', if any, is unchanged.\n", user.c_str());
        SecureZeroMemory(&secret[0], secret.size());
        return 1;
    }
    SecureZeroMemory(&secret[0], secret.size());

    tac::UserState fresh = {};
    fresh.lastStep = confirmedStep;
    tac::StateLock lock;
    if (!lock.Held() || !tac::SaveState(sid, fresh))
    {
        wprintf(L"The secret was stored, but the old lockout state of '%s' could not be\n", user.c_str());
        wprintf(L"reset. Run 'enroll /unlock %s' from an elevated prompt.\n", user.c_str());
        return 1;
    }

    wprintf(L"\nEnrolled '%s'. Test the logon now, before you import the filter.\n", user.c_str());
    wprintf(L"Wait for the NEXT code in the app: the one you just typed is used up, and\n");
    wprintf(L"typing it again at the logon screen counts as a wrong code.\n");
    wprintf(L"Clear this window afterwards (cls), the secret is still in the scrollback.\n");
    return 0;
}

Run it like this:

enroll.exe alice

The tool shows you the secret and an otpauth:// URI. You can type the secret into your authenticator app or convert the URI into a QR code. Please use a generator that runs locally, for example qrencode -t ansiutf8 'otpauth://...' in WSL. An online QR code generator gets your secret, and then it's not a secret anymore. Also clear the console window afterwards (cls). The secret is still in the scrollback.

The tool first checks if the name is really a local user account on this machine (ResolveLocalUserSid). Without this check a typo would create a secret for an account that doesn't exist, and you would only notice it at the logon screen.

Then it shows the secret and waits for a code from your authenticator app. Only if this code is correct, the secret is stored, under the SID of the account. My first version stored the secret right away. One typo when you copy the secret into the app, and the account is locked out at the next logon. Now a wrong secret in the app just means nothing was stored. An existing enrollment keeps working.

A new secret starts clean: no lock, and the code you just typed counts as used. So wait for the next code before you test the logon. If you type the same code again on the logon screen, it counts as a wrong code.

If the user is already enrolled, the tool asks before it overwrites the secret. Otherwise the code on the phone would suddenly stop working.

With /unlock an admin can unlock a user who typed too many wrong codes:

enroll.exe /unlock alice

This resets the counter and the lock, but not the last used time step. An unlock should never make an old code valid again.

Four more switches:

  • enroll.exe /list shows every stored secret with its account, and whether the two indexes for Part 2 know it. The secret of an account that was deleted shows up as "(deleted account)".
  • enroll.exe /remove alice deletes the enrollment again: secret, state and the index entries. If you already deleted the Windows account, the name only finds the name entry. For the secret, take the SID from /list: enroll.exe /remove S-1-5-21-...-1001. My first version said /remove with the name was enough. It wasn't. The secret stayed, and for the filter it still counted as an enrollment.
  • enroll.exe /reindex rebuilds the index entries for Part 2. You need it once after an update and after renaming an enrolled account.
  • enroll.exe /purge deletes all 2FA data on the machine, for a full uninstall.

Every run except /list and /purge also sets the ACL of the store keys again, see the secret store above.

The Credential Provider

Fields of the tile

In common.h I define which fields the tile has:

C++
#pragma once
#include <windows.h>
#include <credentialprovider.h>
#include <shlguid.h>

// On a user tile Windows draws the account name and the round account picture
// itself. TFI_TILEIMAGE and TFI_LABEL are only the small logo and the name of
// our provider under "Sign-in options" (that is what the CPFG_ GUIDs mean).
enum TAC_FIELD_ID
{
    TFI_TILEIMAGE  = 0,
    TFI_LABEL      = 1,
    TFI_USERNAME   = 2,   // only visible on the fallback tile
    TFI_PASSWORD   = 3,
    TFI_OTP        = 4,
    TFI_SUBMIT     = 5,
    TFI_NUM_FIELDS = 6,
};

struct FIELD_STATE_PAIR
{
    CREDENTIAL_PROVIDER_FIELD_STATE             cpfs;
    CREDENTIAL_PROVIDER_FIELD_INTERACTIVE_STATE cpfis;
};

static const CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR s_rgFieldDescriptors[] =
{
    { TFI_TILEIMAGE, CPFT_TILE_IMAGE,    const_cast<PWSTR>(L"Image"), CPFG_CREDENTIAL_PROVIDER_LOGO },
    { TFI_LABEL,     CPFT_SMALL_TEXT,    const_cast<PWSTR>(L"TheAdminCafe 2FA"), CPFG_CREDENTIAL_PROVIDER_LABEL },
    { TFI_USERNAME,  CPFT_EDIT_TEXT,     const_cast<PWSTR>(L"Username") },
    { TFI_PASSWORD,  CPFT_PASSWORD_TEXT, const_cast<PWSTR>(L"Password") },
    { TFI_OTP,       CPFT_PASSWORD_TEXT, const_cast<PWSTR>(L"One-time code") },
    { TFI_SUBMIT,    CPFT_SUBMIT_BUTTON, const_cast<PWSTR>(L"Submit") },
};

// States for a user tile. CTacCredential::Initialize changes them for the
// fallback tile and the RDP tile.
static const FIELD_STATE_PAIR s_rgFieldStatePairs[] =
{
    { CPFS_DISPLAY_IN_BOTH,          CPFIS_NONE    },   // TFI_TILEIMAGE
    { CPFS_HIDDEN,                   CPFIS_NONE    },   // TFI_LABEL
    { CPFS_HIDDEN,                   CPFIS_NONE    },   // TFI_USERNAME
    { CPFS_DISPLAY_IN_SELECTED_TILE, CPFIS_FOCUSED },   // TFI_PASSWORD
    { CPFS_DISPLAY_IN_SELECTED_TILE, CPFIS_NONE    },   // TFI_OTP
    { CPFS_DISPLAY_IN_SELECTED_TILE, CPFIS_NONE    },   // TFI_SUBMIT
};

// The provider reports TFI_NUM_FIELDS descriptors and LogonUI then asks for each
// one by index. If the enum and these tables ever drift apart, that is a read
// past the end of the array on the logon screen, so let the compiler check it.
static_assert(ARRAYSIZE(s_rgFieldDescriptors) == TFI_NUM_FIELDS,
              "s_rgFieldDescriptors must have one entry per TAC_FIELD_ID");
static_assert(ARRAYSIZE(s_rgFieldStatePairs) == TFI_NUM_FIELDS,
              "s_rgFieldStatePairs must have one entry per TAC_FIELD_ID");

The type (CPFT_*) tells LogonUI how to show the field. The tile is bound to a user (see below), so Windows draws the account name and its round picture itself, exactly like the built-in tiles. The image field here is only the small logo of our provider under "Sign-in options", and the label is its name. The username field is hidden on normal user tiles. It is only visible on a fallback tile that I explain below.

The provider

CTacProvider.h:

C++
#pragma once
#include <windows.h>
#include <credentialprovider.h>
#include <string>
#include <vector>
#include "CTacCredential.h"

// Creates one tile per local user, like the built-in Windows tiles. Windows
// hands us the users through ICredentialProviderSetUserArray; because each tile
// is bound to a user (via GetUserSid), Windows draws the name and round picture.
class CTacProvider : public ICredentialProvider,
                     public ICredentialProviderSetUserArray
{
public:
    CTacProvider();

    // IUnknown
    IFACEMETHODIMP_(ULONG) AddRef();
    IFACEMETHODIMP_(ULONG) Release();
    IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv);

    // ICredentialProvider
    IFACEMETHODIMP SetUsageScenario(CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus, DWORD dwFlags);
    IFACEMETHODIMP SetSerialization(const CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcs);
    IFACEMETHODIMP Advise(ICredentialProviderEvents* pcpe, UINT_PTR upAdviseContext);
    IFACEMETHODIMP UnAdvise();
    IFACEMETHODIMP GetFieldDescriptorCount(DWORD* pdwCount);
    IFACEMETHODIMP GetFieldDescriptorAt(DWORD dwIndex, CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR** ppcpfd);
    IFACEMETHODIMP GetCredentialCount(DWORD* pdwCount, DWORD* pdwDefault, BOOL* pbAutoLogonWithDefault);
    IFACEMETHODIMP GetCredentialAt(DWORD dwIndex, ICredentialProviderCredential** ppcpc);

    // ICredentialProviderSetUserArray
    IFACEMETHODIMP SetUserArray(ICredentialProviderUserArray* users);

    friend HRESULT CTacProvider_CreateInstance(REFIID riid, void** ppv);

private:
    virtual ~CTacProvider();

    void    _ClearCredentials();
    HRESULT _CreateCredentials();
    HRESULT _AddCredential(PCWSTR pwzUser, PCWSTR pwzPassword, PCWSTR pwzSid);

    LONG                               _cRef;
    CREDENTIAL_PROVIDER_USAGE_SCENARIO _cpus;
    ICredentialProviderUserArray*      _pUserArray;
    std::vector<CTacCredential*>       _credentials;
    bool                               _built;
    DWORD                              _defaultIndex;

    // Set by SetSerialization when RDP forwards a credential to us.
    bool                               _haveRemote;
    std::wstring                       _remoteUser;
    std::wstring                       _remotePassword;
};

CTacProvider.cpp:

C++
#include "CTacProvider.h"
#include "helpers.h"
#include "common.h"
#include "dll.h"
#include "guid.h"

#include <ntsecapi.h>
#include <new>

// System.Identity.QualifiedUserName. This is the "DOMAIN\user" (or "PC\user")
// form LSA needs. The key is defined by value here so we do not have to link
// propsys just for one property. See the Microsoft property documentation.
static const PROPERTYKEY PKEY_QualifiedUserName =
    { { 0xDA520E51, 0xF4E9, 0x4739, { 0xAC, 0x82, 0x02, 0xE0, 0xA9, 0x5C, 0x90, 0x30 } }, 100 };

CTacProvider::CTacProvider()
    : _cRef(1), _cpus(CPUS_INVALID), _pUserArray(nullptr), _built(false),
      _defaultIndex(CREDENTIAL_PROVIDER_NO_DEFAULT), _haveRemote(false)
{
    DllAddRef();
}

CTacProvider::~CTacProvider()
{
    _ClearCredentials();
    if (!_remotePassword.empty())
        SecureZeroMemory(&_remotePassword[0], _remotePassword.size() * sizeof(WCHAR));
    if (_pUserArray)
        _pUserArray->Release();
    DllRelease();
}

void CTacProvider::_ClearCredentials()
{
    for (CTacCredential* c : _credentials)
        c->Release();
    _credentials.clear();
    _built = false;
    _defaultIndex = CREDENTIAL_PROVIDER_NO_DEFAULT;
}

// --- IUnknown ---------------------------------------------------------------

IFACEMETHODIMP_(ULONG) CTacProvider::AddRef()
{
    return InterlockedIncrement(&_cRef);
}

IFACEMETHODIMP_(ULONG) CTacProvider::Release()
{
    LONG cRef = InterlockedDecrement(&_cRef);
    if (cRef == 0)
        delete this;
    return cRef;
}

IFACEMETHODIMP CTacProvider::QueryInterface(REFIID riid, void** ppv)
{
    if (!ppv)
        return E_POINTER;
    if (IsEqualIID(riid, IID_IUnknown) || IsEqualIID(riid, __uuidof(ICredentialProvider)))
    {
        *ppv = static_cast<ICredentialProvider*>(this);
        AddRef();
        return S_OK;
    }
    if (IsEqualIID(riid, __uuidof(ICredentialProviderSetUserArray)))
    {
        *ppv = static_cast<ICredentialProviderSetUserArray*>(this);
        AddRef();
        return S_OK;
    }
    *ppv = nullptr;
    return E_NOINTERFACE;
}

// --- ICredentialProvider ----------------------------------------------------

IFACEMETHODIMP CTacProvider::SetUsageScenario(CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus, DWORD)
{
    switch (cpus)
    {
    case CPUS_LOGON:
    case CPUS_UNLOCK_WORKSTATION:
        _cpus = cpus;
        return S_OK;

    // We take part in logon and unlock only. Declining the rest cleanly is
    // important: misbehaving in CPUS_CREDUI would break UAC prompts.
    case CPUS_CHANGE_PASSWORD:
    case CPUS_CREDUI:
    case CPUS_PLAP:
        return E_NOTIMPL;

    default:
        return E_INVALIDARG;
    }
}

// A qualified name is local when the part before the backslash is this computer.
// "CONTOSO\alice" or "MicrosoftAccount\..." are not, and get no tile: secrets
// exist only for SIDs of local accounts, and this demo authenticates only against
// the local SAM.
static bool IsLocalAccount(PCWSTR qualified)
{
    const wchar_t* slash = qualified ? wcschr(qualified, L'\\') : nullptr;
    if (!slash)
        return false;

    WCHAR computer[MAX_COMPUTERNAME_LENGTH + 1] = {};
    DWORD cchComputer = ARRAYSIZE(computer);
    if (!GetComputerNameW(computer, &cchComputer))
        return false;

    size_t domainLen = static_cast<size_t>(slash - qualified);
    return domainLen == cchComputer && _wcsnicmp(qualified, computer, domainLen) == 0;
}

HRESULT CTacProvider::_AddCredential(PCWSTR pwzUser, PCWSTR pwzPassword, PCWSTR pwzSid)
{
    CTacCredential* cred = new (std::nothrow) CTacCredential();
    if (!cred)
        return E_OUTOFMEMORY;

    HRESULT hr = cred->Initialize(_cpus, s_rgFieldDescriptors, s_rgFieldStatePairs,
                                  pwzUser, pwzPassword, pwzSid);
    if (SUCCEEDED(hr))
    {
        try
        {
            _credentials.push_back(cred);
            return S_OK;
        }
        catch (...)   // std::bad_alloc must not cross into LogonUI
        {
            hr = E_OUTOFMEMORY;
        }
    }
    cred->Release();
    return hr;
}

HRESULT CTacProvider::_CreateCredentials()
{
    if (_built)
        return S_OK;
    _ClearCredentials();

    // RDP path: one tile, pre-filled with the forwarded name and password.
    if (_haveRemote)
    {
        HRESULT hr = _AddCredential(_remoteUser.c_str(), _remotePassword.c_str(), nullptr);
        if (FAILED(hr))
            return hr;      // keep the forwarded credential for LogonUI's next try
        _defaultIndex = 0;

        // The tile has its own copy now, so drop ours.
        if (!_remotePassword.empty())
        {
            SecureZeroMemory(&_remotePassword[0], _remotePassword.size() * sizeof(WCHAR));
            _remotePassword.clear();
        }
        _built = true;
        return S_OK;
    }

    // One tile per listed local user. A user that cannot be read is skipped.
    // Out of memory stops the loop; if not a single tile could be created,
    // that is reported to LogonUI instead of pretending the enumeration worked.
    DWORD count = 0;
    if (_pUserArray && FAILED(_pUserArray->GetCount(&count)))
        count = 0;

    HRESULT hr = S_OK;
    for (DWORD i = 0; i < count && hr != E_OUTOFMEMORY; ++i)
    {
        ICredentialProviderUser* user = nullptr;
        if (FAILED(_pUserArray->GetAt(i, &user)) || !user)
            continue;

        PWSTR qualified = nullptr;
        PWSTR sid       = nullptr;
        if (SUCCEEDED(user->GetStringValue(PKEY_QualifiedUserName, &qualified)) &&
            SUCCEEDED(user->GetSid(&sid)) &&
            qualified && sid && IsLocalAccount(qualified))
            hr = _AddCredential(qualified, L"", sid);

        CoTaskMemFree(qualified);
        CoTaskMemFree(sid);
        user->Release();
    }

    // Always one generic tile with a username field ("Other user") at logon.
    // With the filter on, this is the only way in for an account Windows does
    // not list: a hidden break-glass admin, or any account when Windows lists
    // no users at all. On the unlock screen it is only added if nothing else
    // could be created.
    if (hr != E_OUTOFMEMORY && (_cpus == CPUS_LOGON || _credentials.empty()))
        hr = _AddCredential(L"", L"", nullptr);

    if (FAILED(hr) && _credentials.empty())
        return hr;          // nothing usable at all: say so, LogonUI retries later

    _built = true;
    return S_OK;
}

// RDP with NLA forwards the credential over CredSSP before any tile is drawn.
// We unpack it and remember the name and password to pre-fill the tile.
IFACEMETHODIMP CTacProvider::SetSerialization(
    const CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcs)
{
    if (!pcpcs || !pcpcs->rgbSerialization ||
        pcpcs->cbSerialization < sizeof(KERB_INTERACTIVE_UNLOCK_LOGON))
        return E_NOTIMPL;
    // Domain, name and password are each at most 64 KB (USHORT lengths); a
    // larger buffer is not a password logon, so do not copy it.
    if (pcpcs->cbSerialization > sizeof(KERB_INTERACTIVE_UNLOCK_LOGON) + 3 * 0x10000)
        return E_INVALIDARG;
    if (!IsEqualCLSID(pcpcs->clsidCredentialProvider, CLSID_CTacProvider))
        return E_NOTIMPL;   // not addressed to us

    // Work on our own copy, because unpacking rewrites the offsets to pointers.
    KERB_INTERACTIVE_UNLOCK_LOGON* pkiul =
        static_cast<KERB_INTERACTIVE_UNLOCK_LOGON*>(CoTaskMemAlloc(pcpcs->cbSerialization));
    if (!pkiul)
        return E_OUTOFMEMORY;
    CopyMemory(pkiul, pcpcs->rgbSerialization, pcpcs->cbSerialization);

    // RDP can also forward other logon types, a smartcard logon for example.
    // Only a password logon has the layout we unpack below.
    HRESULT hr = S_OK;
    if (pkiul->Logon.MessageType != KerbInteractiveLogon &&
        pkiul->Logon.MessageType != KerbWorkstationUnlockLogon)
        hr = E_NOTIMPL;

    if (SUCCEEDED(hr))
        hr = KerbInteractiveUnlockLogonUnpackInPlace(pkiul, pcpcs->cbSerialization);
    if (SUCCEEDED(hr))
    {
        const KERB_INTERACTIVE_LOGON& kil = pkiul->Logon;

        // A UNICODE_STRING is counted, not null-terminated, and Buffer can be
        // null (empty domain), which std::wstring must not be built from.
        auto toString = [](const UNICODE_STRING& us) -> std::wstring {
            if (!us.Buffer || us.Length == 0)
                return std::wstring();
            return std::wstring(us.Buffer, us.Length / sizeof(WCHAR));
        };

        try
        {
            std::wstring domain = toString(kil.LogonDomainName);
            std::wstring user   = toString(kil.UserName);
            std::wstring pass   = toString(kil.Password);

            if (!_remotePassword.empty())
                SecureZeroMemory(&_remotePassword[0], _remotePassword.size() * sizeof(WCHAR));

            _remoteUser     = domain.empty() ? user : (domain + L"\\" + user);
            _remotePassword = pass;
            _haveRemote     = true;
            _built          = false;

            if (!pass.empty())
                SecureZeroMemory(&pass[0], pass.size() * sizeof(WCHAR));
        }
        catch (...)
        {
            hr = E_OUTOFMEMORY;
        }
    }

    SecureZeroMemory(pkiul, pcpcs->cbSerialization);
    CoTaskMemFree(pkiul);
    return hr;
}

IFACEMETHODIMP CTacProvider::Advise(ICredentialProviderEvents*, UINT_PTR) { return S_OK; }
IFACEMETHODIMP CTacProvider::UnAdvise() { return S_OK; }

IFACEMETHODIMP CTacProvider::GetFieldDescriptorCount(DWORD* pdwCount)
{
    if (!pdwCount)
        return E_POINTER;
    *pdwCount = TFI_NUM_FIELDS;
    return S_OK;
}

IFACEMETHODIMP CTacProvider::GetFieldDescriptorAt(DWORD dwIndex,
                                                  CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR** ppcpfd)
{
    if (dwIndex >= TFI_NUM_FIELDS || !ppcpfd)
        return E_INVALIDARG;
    return FieldDescriptorCoAllocCopy(s_rgFieldDescriptors[dwIndex], ppcpfd);
}

IFACEMETHODIMP CTacProvider::GetCredentialCount(DWORD* pdwCount, DWORD* pdwDefault,
                                                BOOL* pbAutoLogonWithDefault)
{
    if (!pdwCount || !pdwDefault || !pbAutoLogonWithDefault)
        return E_POINTER;
    *pdwCount = 0;
    *pdwDefault = CREDENTIAL_PROVIDER_NO_DEFAULT;
    *pbAutoLogonWithDefault = FALSE;

    HRESULT hr = _CreateCredentials();
    if (FAILED(hr))
        return hr;
    *pdwCount = static_cast<DWORD>(_credentials.size());
    *pdwDefault = _defaultIndex;
    return S_OK;
}

IFACEMETHODIMP CTacProvider::GetCredentialAt(DWORD dwIndex,
                                             ICredentialProviderCredential** ppcpc)
{
    if (!ppcpc)
        return E_INVALIDARG;
    *ppcpc = nullptr;
    HRESULT hr = _CreateCredentials();
    if (FAILED(hr))
        return hr;
    if (dwIndex >= _credentials.size())
        return E_INVALIDARG;
    return _credentials[dwIndex]->QueryInterface(IID_PPV_ARGS(ppcpc));
}

// --- ICredentialProviderSetUserArray ----------------------------------------

IFACEMETHODIMP CTacProvider::SetUserArray(ICredentialProviderUserArray* users)
{
    if (_pUserArray)
        _pUserArray->Release();
    _pUserArray = users;
    if (_pUserArray)
        _pUserArray->AddRef();
    _built = false;
    return S_OK;
}

HRESULT CTacProvider_CreateInstance(REFIID riid, void** ppv)
{
    CTacProvider* provider = new (std::nothrow) CTacProvider();
    if (!provider)
        return E_OUTOFMEMORY;
    HRESULT hr = provider->QueryInterface(riid, ppv);
    provider->Release();
    return hr;
}

The provider creates one tile per local user. So the logon screen looks like the normal one from Windows. This is how it works:

  1. LogonUI calls SetUserArray and gives us the list of users.
  2. When LogonUI asks for the tiles (GetCredentialCount and GetCredentialAt), _CreateCredentials goes through this list. For every user it reads the name (PC\alice) and the SID.
  3. For every local account it creates a CTacCredential with this name and SID. The tile returns the SID in GetUserSid. This one function makes Windows show the name and the round profile picture.

Only local accounts get a tile. You can see the type of account in the name: PC\alice is local, CONTOSO\alice is a domain account and MicrosoftAccount\alice@... is a Microsoft account. IsLocalAccount checks if the part before the backslash is the computer name. All other accounts are skipped, because the demo only logs on against the local SAM.

There is one more tile. At logon the provider always adds a tile with a username field, "Other user". On some machines, mostly domain joined ones, Windows doesn't give us any local users. Then this is the only tile. And with the filter it is the only way in for an account that Windows doesn't list, for example a hidden break-glass admin. My first version only created this tile if there was no other one. So a hidden admin had no way in. On the unlock screen it is only added if no other tile could be created.

This is also why the tile has a username field. On a normal user tile it is hidden, because the name comes from the account. On the "Other user" tile it is visible. The RDP tile uses it too.

Why user tiles at all? My first version had a single tile with a username field and its own picture. It worked, but the picture was always square, not round like on Windows 11. So I made the bitmap round myself with transparent corners. Result: black corners. LogonUI just ignores transparency on a tile that is not bound to a user, and Windows only rounds the pictures of real user tiles. The solution was to bind the tile to a user with GetUserSid. Windows now draws the real account picture, round, and you don't have to type your username anymore.

About PKEY_QualifiedUserName: this is a PROPERTYKEY from the Windows property system. I didn't want to link propsys just for one value, so I define it directly in the code. The values are from the Microsoft documentation (formatID DA520E51-..., propID 100).

SetSerialization is for RDP. With Network Level Authentication the RDP client sends the credentials before a tile is shown. LogonUI passes them to this function.

First I check the MessageType. RDP can also send other logon types, for example a smartcard logon. Only a password logon has the structure I unpack here. Then the name and password are saved and the provider creates a tile where both are already filled in. The code field stays empty, so the code is still required.

The toString lambda handles an edge case. A UNICODE_STRING has a length and is not terminated with a zero. If the domain is empty, the Buffer can be a null pointer. Creating a std::wstring from a null pointer is undefined behavior. You would never see this bug in your own tests, only on someone else's machine.

Two smaller things. If the tiles can't be created at all, for example because there is no memory left, _CreateCredentials tells LogonUI instead of pretending that everything worked. And SetSerialization refuses a buffer that is bigger than any password logon can be, before it copies it. A C++ exception like std::bad_alloc never goes back to LogonUI. It is caught and turned into an error code.

The tile and the code check

CTacCredential.h:

C++
#pragma once
#include <windows.h>
#include <credentialprovider.h>
#include "common.h"

// One tile. ICredentialProviderCredential2 adds GetUserSid, which binds the
// tile to an account. That is what makes Windows show the account's name and
// its round picture, like on the built-in tiles.
class CTacCredential : public ICredentialProviderCredential2
{
public:
    CTacCredential();

    HRESULT Initialize(CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
                       const CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR* rgcpfd,
                       const FIELD_STATE_PAIR* rgfsp,
                       PCWSTR pwzUser, PCWSTR pwzPassword, PCWSTR pwzSid);

    // IUnknown
    IFACEMETHODIMP_(ULONG) AddRef();
    IFACEMETHODIMP_(ULONG) Release();
    IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv);

    // ICredentialProviderCredential
    IFACEMETHODIMP Advise(ICredentialProviderCredentialEvents* pcpce);
    IFACEMETHODIMP UnAdvise();
    IFACEMETHODIMP SetSelected(BOOL* pbAutoLogon);
    IFACEMETHODIMP SetDeselected();
    IFACEMETHODIMP GetFieldState(DWORD dwFieldID,
                                 CREDENTIAL_PROVIDER_FIELD_STATE* pcpfs,
                                 CREDENTIAL_PROVIDER_FIELD_INTERACTIVE_STATE* pcpfis);
    IFACEMETHODIMP GetStringValue(DWORD dwFieldID, PWSTR* ppwsz);
    IFACEMETHODIMP GetBitmapValue(DWORD dwFieldID, HBITMAP* phbmp);
    IFACEMETHODIMP GetCheckboxValue(DWORD dwFieldID, BOOL* pbChecked, PWSTR* ppwszLabel);
    IFACEMETHODIMP GetSubmitButtonValue(DWORD dwFieldID, DWORD* pdwAdjacentTo);
    IFACEMETHODIMP GetComboBoxValueCount(DWORD dwFieldID, DWORD* pcItems, DWORD* pdwSelectedItem);
    IFACEMETHODIMP GetComboBoxValueAt(DWORD dwFieldID, DWORD dwItem, PWSTR* ppwszItem);
    IFACEMETHODIMP SetStringValue(DWORD dwFieldID, PCWSTR pwz);
    IFACEMETHODIMP SetCheckboxValue(DWORD dwFieldID, BOOL bChecked);
    IFACEMETHODIMP SetComboBoxSelectedValue(DWORD dwFieldID, DWORD dwSelectedItem);
    IFACEMETHODIMP CommandLinkClicked(DWORD dwFieldID);
    IFACEMETHODIMP GetSerialization(CREDENTIAL_PROVIDER_GET_SERIALIZATION_RESPONSE* pcpgsr,
                                    CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcs,
                                    PWSTR* ppwszOptionalStatusText,
                                    CREDENTIAL_PROVIDER_STATUS_ICON* pcpsiOptionalStatusIcon);
    IFACEMETHODIMP ReportResult(NTSTATUS ntsStatus, NTSTATUS ntsSubstatus,
                                PWSTR* ppwszOptionalStatusText,
                                CREDENTIAL_PROVIDER_STATUS_ICON* pcpsiOptionalStatusIcon);

    // ICredentialProviderCredential2
    IFACEMETHODIMP GetUserSid(PWSTR* ppszSid);

private:
    virtual ~CTacCredential();

    void _FreeField(DWORD dwFieldID);
    void _ResetField(DWORD dwFieldID);

    // The body of GetSerialization. May throw std::bad_alloc; GetSerialization
    // catches it, so no C++ exception ever reaches LogonUI.
    HRESULT _GetSerializationImpl(CREDENTIAL_PROVIDER_GET_SERIALIZATION_RESPONSE* pcpgsr,
                                  CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcs,
                                  PWSTR* ppwszOptionalStatusText,
                                  CREDENTIAL_PROVIDER_STATUS_ICON* pcpsiOptionalStatusIcon);

    LONG                                 _cRef;
    CREDENTIAL_PROVIDER_USAGE_SCENARIO   _cpus;
    CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR _rgFieldDescriptors[TFI_NUM_FIELDS];
    FIELD_STATE_PAIR                     _rgFieldStatePairs[TFI_NUM_FIELDS];
    PWSTR                                _rgFieldStrings[TFI_NUM_FIELDS];
    PWSTR                                _pszSid;   // null on the fallback and RDP tile
    ICredentialProviderCredentialEvents* _pCredProvCredentialEvents;
};

CTacCredential.cpp:

C++
#include "CTacCredential.h"
#include "helpers.h"
#include "guid.h"
#include "store.h"
#include "verify.h"
#include "eventlog.h"
#include "dll.h"

#include <ntsecapi.h>
#include <shlwapi.h>
#include <strsafe.h>
#include <cstdio>
#include <ctime>
#include <string>
#include <vector>

#pragma comment(lib, "shlwapi.lib")
#pragma comment(lib, "gdi32.lib")
#pragma comment(lib, "user32.lib")

// STATUS_LOGON_FAILURE lives in ntstatus.h, which clashes with windows.h unless
// WIN32_NO_STATUS is set everywhere. Defining the one value we need is simpler.
#ifndef STATUS_LOGON_FAILURE
#define STATUS_LOGON_FAILURE ((NTSTATUS)0xC000006DL)
#endif

CTacCredential::CTacCredential()
    : _cRef(1), _cpus(CPUS_INVALID), _pszSid(nullptr), _pCredProvCredentialEvents(nullptr)
{
    ZeroMemory(_rgFieldDescriptors, sizeof(_rgFieldDescriptors));
    ZeroMemory(_rgFieldStatePairs, sizeof(_rgFieldStatePairs));
    ZeroMemory(_rgFieldStrings, sizeof(_rgFieldStrings));
    DllAddRef();
}

CTacCredential::~CTacCredential()
{
    for (DWORD i = 0; i < TFI_NUM_FIELDS; ++i)
        _FreeField(i);
    CoTaskMemFree(_pszSid);
    if (_pCredProvCredentialEvents)
        _pCredProvCredentialEvents->Release();
    DllRelease();
}

// Wipe and free a field string.
void CTacCredential::_FreeField(DWORD dwFieldID)
{
    PWSTR& s = _rgFieldStrings[dwFieldID];
    if (s)
    {
        size_t len = 0;
        if (SUCCEEDED(StringCchLengthW(s, STRSAFE_MAX_CCH, &len)))
            SecureZeroMemory(s, len * sizeof(WCHAR));
        CoTaskMemFree(s);
        s = nullptr;
    }
}

// Clear a field back to an empty string, both in our copy and on screen.
void CTacCredential::_ResetField(DWORD dwFieldID)
{
    _FreeField(dwFieldID);
    SHStrDupW(L"", &_rgFieldStrings[dwFieldID]);
    if (_pCredProvCredentialEvents)
        _pCredProvCredentialEvents->SetFieldString(this, dwFieldID, L"");
}

HRESULT CTacCredential::Initialize(CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
                                   const CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR* rgcpfd,
                                   const FIELD_STATE_PAIR* rgfsp,
                                   PCWSTR pwzUser, PCWSTR pwzPassword, PCWSTR pwzSid)
{
    _cpus = cpus;
    for (DWORD i = 0; i < TFI_NUM_FIELDS; ++i)
    {
        _rgFieldDescriptors[i] = rgcpfd[i];
        _rgFieldStatePairs[i]  = rgfsp[i];
    }

    const bool hasUser = pwzUser && *pwzUser;
    const bool hasPass = pwzPassword && *pwzPassword;

    // A tile without a SID is the fallback or RDP tile: it is not bound to an
    // account, so the username field is shown. Put the caret on the first empty
    // field.
    if (!pwzSid)
    {
        _rgFieldStatePairs[TFI_USERNAME] = { CPFS_DISPLAY_IN_SELECTED_TILE, CPFIS_NONE };
        _rgFieldStatePairs[TFI_PASSWORD].cpfis = CPFIS_NONE;
        if (!hasUser)
            _rgFieldStatePairs[TFI_USERNAME].cpfis = CPFIS_FOCUSED;
        else if (!hasPass)
            _rgFieldStatePairs[TFI_PASSWORD].cpfis = CPFIS_FOCUSED;
        else
            _rgFieldStatePairs[TFI_OTP].cpfis = CPFIS_FOCUSED;
    }

    HRESULT hr = pwzSid ? SHStrDupW(pwzSid, &_pszSid) : S_OK;
    if (SUCCEEDED(hr))
        hr = SHStrDupW(L"TheAdminCafe 2FA", &_rgFieldStrings[TFI_LABEL]);
    if (SUCCEEDED(hr))
        hr = SHStrDupW(hasUser ? pwzUser : L"", &_rgFieldStrings[TFI_USERNAME]);
    if (SUCCEEDED(hr))
        hr = SHStrDupW(hasPass ? pwzPassword : L"", &_rgFieldStrings[TFI_PASSWORD]);
    if (SUCCEEDED(hr))
        hr = SHStrDupW(L"", &_rgFieldStrings[TFI_OTP]);
    return hr;
}

// --- IUnknown ---------------------------------------------------------------

IFACEMETHODIMP_(ULONG) CTacCredential::AddRef()
{
    return InterlockedIncrement(&_cRef);
}

IFACEMETHODIMP_(ULONG) CTacCredential::Release()
{
    LONG cRef = InterlockedDecrement(&_cRef);
    if (cRef == 0)
        delete this;
    return cRef;
}

IFACEMETHODIMP CTacCredential::QueryInterface(REFIID riid, void** ppv)
{
    if (!ppv)
        return E_POINTER;
    if (IsEqualIID(riid, IID_IUnknown) ||
        IsEqualIID(riid, __uuidof(ICredentialProviderCredential)) ||
        IsEqualIID(riid, __uuidof(ICredentialProviderCredential2)))
    {
        *ppv = static_cast<ICredentialProviderCredential2*>(this);
        AddRef();
        return S_OK;
    }
    *ppv = nullptr;
    return E_NOINTERFACE;
}

// --- ICredentialProviderCredential ------------------------------------------

IFACEMETHODIMP CTacCredential::Advise(ICredentialProviderCredentialEvents* pcpce)
{
    if (_pCredProvCredentialEvents)
        _pCredProvCredentialEvents->Release();
    _pCredProvCredentialEvents = pcpce;
    if (_pCredProvCredentialEvents)
        _pCredProvCredentialEvents->AddRef();
    return S_OK;
}

IFACEMETHODIMP CTacCredential::UnAdvise()
{
    if (_pCredProvCredentialEvents)
    {
        _pCredProvCredentialEvents->Release();
        _pCredProvCredentialEvents = nullptr;
    }
    return S_OK;
}

IFACEMETHODIMP CTacCredential::SetSelected(BOOL* pbAutoLogon)
{
    if (!pbAutoLogon)
        return E_POINTER;
    *pbAutoLogon = FALSE;   // we always need the code, so never log on automatically
    return S_OK;
}

IFACEMETHODIMP CTacCredential::SetDeselected()
{
    // Do not leave the password or the code in memory once the tile is left.
    _ResetField(TFI_PASSWORD);
    _ResetField(TFI_OTP);
    return S_OK;
}

IFACEMETHODIMP CTacCredential::GetFieldState(DWORD dwFieldID,
                                             CREDENTIAL_PROVIDER_FIELD_STATE* pcpfs,
                                             CREDENTIAL_PROVIDER_FIELD_INTERACTIVE_STATE* pcpfis)
{
    if (dwFieldID >= TFI_NUM_FIELDS)
        return E_INVALIDARG;
    if (!pcpfs || !pcpfis)
        return E_POINTER;
    *pcpfs  = _rgFieldStatePairs[dwFieldID].cpfs;
    *pcpfis = _rgFieldStatePairs[dwFieldID].cpfis;
    return S_OK;
}

IFACEMETHODIMP CTacCredential::GetStringValue(DWORD dwFieldID, PWSTR* ppwsz)
{
    if (dwFieldID >= TFI_NUM_FIELDS)
        return E_INVALIDARG;
    if (!ppwsz)
        return E_POINTER;
    *ppwsz = nullptr;
    return SHStrDupW(_rgFieldStrings[dwFieldID] ? _rgFieldStrings[dwFieldID] : L"", ppwsz);
}

// The provider logo, drawn in code so no image file is required. A coffee-brown
// square with "2FA". This is only the small icon under "Sign-in options"; the
// large round picture on a user tile is the account picture drawn by Windows.
static HBITMAP CreateLogo()
{
    const int size = 128;

    BITMAPINFO bmi = {};
    bmi.bmiHeader.biSize     = sizeof(BITMAPINFOHEADER);
    bmi.bmiHeader.biWidth    = size;
    bmi.bmiHeader.biHeight   = -size;   // top-down
    bmi.bmiHeader.biPlanes   = 1;
    bmi.bmiHeader.biBitCount = 24;      // no alpha channel to worry about
    bmi.bmiHeader.biCompression = BI_RGB;

    void* pvBits = nullptr;
    HBITMAP hbmp = CreateDIBSection(nullptr, &bmi, DIB_RGB_COLORS, &pvBits, nullptr, 0);
    if (!hbmp)
        return nullptr;

    HDC hdc = CreateCompatibleDC(nullptr);
    if (!hdc)
    {
        DeleteObject(hbmp);
        return nullptr;
    }

    HGDIOBJ hOldBmp = SelectObject(hdc, hbmp);

    RECT rc = { 0, 0, size, size };
    HBRUSH hBrush = CreateSolidBrush(RGB(0x6F, 0x4E, 0x37));
    FillRect(hdc, &rc, hBrush);
    DeleteObject(hBrush);

    HFONT hFont = CreateFontW(-48, 0, 0, 0, FW_SEMIBOLD, FALSE, FALSE, FALSE,
                              DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
                              ANTIALIASED_QUALITY, DEFAULT_PITCH | FF_SWISS, L"Segoe UI");
    HGDIOBJ hOldFont = hFont ? SelectObject(hdc, hFont) : nullptr;
    SetBkMode(hdc, TRANSPARENT);
    SetTextColor(hdc, RGB(0xFF, 0xFF, 0xFF));
    DrawTextW(hdc, L"2FA", -1, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE);

    if (hOldFont)
        SelectObject(hdc, hOldFont);
    if (hFont)
        DeleteObject(hFont);
    SelectObject(hdc, hOldBmp);
    DeleteDC(hdc);
    GdiFlush();
    return hbmp;
}

IFACEMETHODIMP CTacCredential::GetBitmapValue(DWORD dwFieldID, HBITMAP* phbmp)
{
    if (dwFieldID != TFI_TILEIMAGE || !phbmp)
        return E_INVALIDARG;

    HBITMAP hbmp = CreateLogo();
    if (!hbmp)
        return E_FAIL;

    *phbmp = hbmp;   // LogonUI takes ownership and deletes it
    return S_OK;
}

IFACEMETHODIMP CTacCredential::GetSubmitButtonValue(DWORD dwFieldID, DWORD* pdwAdjacentTo)
{
    if (dwFieldID != TFI_SUBMIT || !pdwAdjacentTo)
        return E_INVALIDARG;
    *pdwAdjacentTo = TFI_OTP;   // the arrow sits next to the code field
    return S_OK;
}

IFACEMETHODIMP CTacCredential::SetStringValue(DWORD dwFieldID, PCWSTR pwz)
{
    if (dwFieldID != TFI_USERNAME && dwFieldID != TFI_PASSWORD && dwFieldID != TFI_OTP)
        return E_INVALIDARG;
    _FreeField(dwFieldID);
    return SHStrDupW(pwz ? pwz : L"", &_rgFieldStrings[dwFieldID]);
}

// Our tile has no checkbox or combo box fields.
IFACEMETHODIMP CTacCredential::GetCheckboxValue(DWORD, BOOL*, PWSTR*)       { return E_NOTIMPL; }
IFACEMETHODIMP CTacCredential::GetComboBoxValueCount(DWORD, DWORD*, DWORD*) { return E_NOTIMPL; }
IFACEMETHODIMP CTacCredential::GetComboBoxValueAt(DWORD, DWORD, PWSTR*)     { return E_NOTIMPL; }
IFACEMETHODIMP CTacCredential::SetCheckboxValue(DWORD, BOOL)                { return E_NOTIMPL; }
IFACEMETHODIMP CTacCredential::SetComboBoxSelectedValue(DWORD, DWORD)       { return E_NOTIMPL; }
IFACEMETHODIMP CTacCredential::CommandLinkClicked(DWORD)                    { return E_NOTIMPL; }

// --- ICredentialProviderCredential2 -----------------------------------------

// Returning a SID binds the tile to that account, so Windows shows its name and
// round picture. The fallback and RDP tiles have no SID and return S_FALSE,
// which makes them a generic tile with a username field.
IFACEMETHODIMP CTacCredential::GetUserSid(PWSTR* ppszSid)
{
    if (!ppszSid)
        return E_POINTER;
    *ppszSid = nullptr;
    if (!_pszSid)
        return S_FALSE;
    return SHStrDupW(_pszSid, ppszSid);
}

// --- the actual work: password, then code, then serialize -------------------

enum class PasswordCheck { Ok, Wrong, Refused };

// Asks Windows whether the password is right, before the code is looked at.
//
// The order matters. Every wrong code counts toward the lock, so if the code
// came first, anyone at the logon screen - or anyone with any RDP account,
// since the RDP tile lets you type another name - could lock every enrolled
// account out without knowing a single password. Checking the password first
// means only someone who already has factor one can burn guesses on factor
// two. Wrong passwords are counted by Windows itself (account lockout policy),
// exactly as on the built-in tile.
//
// LogonUser with LOGON32_LOGON_INTERACTIVE is used because the deny rights and
// the sub-authentication filter of this project block network logons for
// enrolled accounts. The token is closed right away; the side effect is one
// extra logon event (4624, type 2, process LogonUI.exe) per sign-in.
//
// A password forwarded by RDP can arrive CredProtect-ed; LogonUser needs the
// plain text, so it is unprotected into a temporary copy that is wiped below.
static PasswordCheck CheckPassword(PCWSTR domain, PCWSTR user, PCWSTR password, DWORD& error)
{
    error = ERROR_SUCCESS;

    PWSTR plain = nullptr;
    HRESULT hr = CopyUnprotectedPassword(password, &plain);
    if (FAILED(hr))
    {
        error = static_cast<DWORD>(HRESULT_CODE(hr));
        return PasswordCheck::Refused;
    }

    HANDLE token = nullptr;
    BOOL ok = LogonUserW(user, domain, plain, LOGON32_LOGON_INTERACTIVE,
                         LOGON32_PROVIDER_DEFAULT, &token);
    error = ok ? ERROR_SUCCESS : GetLastError();

    SecureZeroMemory(plain, wcslen(plain) * sizeof(WCHAR));
    CoTaskMemFree(plain);

    if (ok)
    {
        CloseHandle(token);
        return PasswordCheck::Ok;
    }

    switch (error)
    {
    // These are only reported after the password was accepted. The real logon
    // below runs into the same condition and Windows handles it there (for
    // example "Allow log on locally" missing for an RDP-only user on a server).
    // ERROR_ACCOUNT_RESTRICTION is what a correct BLANK password gets outside
    // the console ("limit blank passwords to console logon"); the real logon
    // from LogonUI is a console logon, so Windows makes that call there too.
    case ERROR_LOGON_TYPE_NOT_GRANTED:
    case ERROR_PASSWORD_MUST_CHANGE:
    case ERROR_PASSWORD_EXPIRED:
    case ERROR_ACCOUNT_RESTRICTION:
        return PasswordCheck::Ok;

    case ERROR_LOGON_FAILURE:
        return PasswordCheck::Wrong;

    default:   // locked out, disabled, blank password not allowed, ...
        return PasswordCheck::Refused;
    }
}


// Every output is set before any work starts, like in Microsoft's sample:
// LogonUI reads the status text and icon on every return, and may read the
// whole serialization structure. Nothing below may leave one of them
// indeterminate, and no C++ exception may cross back into LogonUI.
IFACEMETHODIMP CTacCredential::GetSerialization(
    CREDENTIAL_PROVIDER_GET_SERIALIZATION_RESPONSE* pcpgsr,
    CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcs,
    PWSTR* ppwszOptionalStatusText,
    CREDENTIAL_PROVIDER_STATUS_ICON* pcpsiOptionalStatusIcon)
{
    if (!pcpgsr || !pcpcs || !ppwszOptionalStatusText || !pcpsiOptionalStatusIcon)
        return E_POINTER;

    *pcpgsr = CPGSR_NO_CREDENTIAL_NOT_FINISHED;
    ZeroMemory(pcpcs, sizeof(*pcpcs));
    *ppwszOptionalStatusText = nullptr;
    *pcpsiOptionalStatusIcon = CPSI_NONE;

    HRESULT hr;
    try
    {
        hr = _GetSerializationImpl(pcpgsr, pcpcs, ppwszOptionalStatusText, pcpsiOptionalStatusIcon);
    }
    catch (...)
    {
        hr = E_OUTOFMEMORY;
    }

    if (FAILED(hr))
    {
        if (pcpcs->rgbSerialization)
        {
            SecureZeroMemory(pcpcs->rgbSerialization, pcpcs->cbSerialization);
            CoTaskMemFree(pcpcs->rgbSerialization);
        }
        ZeroMemory(pcpcs, sizeof(*pcpcs));
        *pcpgsr = CPGSR_NO_CREDENTIAL_NOT_FINISHED;
    }
    return hr;
}

HRESULT CTacCredential::_GetSerializationImpl(
    CREDENTIAL_PROVIDER_GET_SERIALIZATION_RESPONSE* pcpgsr,
    CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcs,
    PWSTR* ppwszOptionalStatusText,
    CREDENTIAL_PROVIDER_STATUS_ICON* pcpsiOptionalStatusIcon)
{

    // The username field holds "PC\user" on a user tile, or whatever was typed
    // on the fallback tile. We only ever use the bare name, so the account we
    // check the code for is the same local account we log on below.
    std::wstring bare = _rgFieldStrings[TFI_USERNAME] ? _rgFieldStrings[TFI_USERNAME] : L"";
    size_t slash = bare.find_last_of(L'\\');
    if (slash != std::wstring::npos)
        bare.erase(0, slash + 1);

    // --- which account is this? ---
    // The secret belongs to a SID. We resolve COMPUTERNAME\name exactly like
    // LSA will. On a user tile the result has to be the SID of the tile.
    std::wstring sid;
    bool known = bare.size() <= 256 && tac::ResolveLocalUserSid(bare, sid);
    if (known && _pszSid && _wcsicmp(sid.c_str(), _pszSid) != 0)
        known = false;

    // Typed names end up in the event log; keep a long one from flooding it.
    const std::wstring shown   = bare.size() > 64 ? bare.substr(0, 64) + L"..." : bare;
    const std::wstring who     = shown + L" (" + (known ? sid : std::wstring(L"unknown account")) + L")";
    const std::wstring session = tac::DescribeSession();

    WCHAR computer[MAX_COMPUTERNAME_LENGTH + 1] = {};
    DWORD cchComputer = ARRAYSIZE(computer);
    if (!GetComputerNameW(computer, &cchComputer))
        return HRESULT_FROM_WIN32(GetLastError());

    // --- the first factor: is the password right? ---
    DWORD pwError = ERROR_NO_SUCH_USER;     // for the log, if the account is unknown
    PasswordCheck pw = known
        ? CheckPassword(computer, bare.c_str(), _rgFieldStrings[TFI_PASSWORD], pwError)
        : PasswordCheck::Wrong;

    if (pw != PasswordCheck::Ok)
    {
        // The code was not looked at and nothing was counted. An unknown
        // account gets the same message as a wrong password.
        tac::LogEvent(EVENTLOG_WARNING_TYPE, tac::EVT_PASSWORD_BAD,
                      L"Password refused for " + who + L" (error " + std::to_wstring(pwError) +
                          L"). The one-time code was not checked. " + session + L".");

        PCWSTR message = L"The user name or password is incorrect.";
        if (pw == PasswordCheck::Refused)
            message = (pwError == ERROR_ACCOUNT_LOCKED_OUT)
                ? L"This account is locked by Windows. Try again later."
                : L"Windows refused the logon for this account.";

        _ResetField(TFI_PASSWORD);
        _ResetField(TFI_OTP);
        SHStrDupW(message, ppwszOptionalStatusText);
        *pcpsiOptionalStatusIcon = CPSI_ERROR;
        return S_OK;   // S_OK keeps the tile alive; a failing HRESULT would kill it
    }

    // --- the second factor ---
    std::wstring code = _rgFieldStrings[TFI_OTP] ? _rgFieldStrings[TFI_OTP] : L"";
    tac::OtpInfo info = {};
    tac::OtpResult result = tac::VerifyOtp(sid, code, static_cast<uint64_t>(time(nullptr)), info);
    if (!code.empty())
        SecureZeroMemory(&code[0], code.size() * sizeof(WCHAR));

    if (result != tac::OtpResult::Ok)
    {
        // "Not enrolled", "wrong" and "already used" look the same on screen.
        // Only the lock gets its own message, otherwise a locked user would
        // keep typing valid codes.
        PCWSTR message = L"Invalid one-time code.";
        wchar_t lockText[128];

        switch (result)
        {
        case tac::OtpResult::Wrong:
        case tac::OtpResult::Replayed:
            tac::LogEvent(EVENTLOG_WARNING_TYPE,
                          result == tac::OtpResult::Wrong ? tac::EVT_CODE_WRONG : tac::EVT_CODE_REPLAYED,
                          (result == tac::OtpResult::Wrong ? L"Wrong one-time code for "
                                                           : L"Already used one-time code for ") +
                              who + L" (password was correct). Wrong codes in a row: " +
                              std::to_wstring(info.failures) + L". " + session + L".");
            if (info.minutesLeft)
            {
                tac::LogEvent(EVENTLOG_ERROR_TYPE, tac::EVT_LOCK_STARTED,
                              L"Account " + who + L" locked for " + std::to_wstring(info.minutesLeft) +
                                  L" minutes after " + std::to_wstring(info.failures) +
                                  L" wrong codes in a row. " + session + L".");
                swprintf_s(lockText, L"Too many wrong codes. Try again in %u minutes.", info.minutesLeft);
                message = lockText;
            }
            break;

        case tac::OtpResult::LockedOut:
            tac::LogEvent(EVENTLOG_WARNING_TYPE, tac::EVT_LOCKED_OUT,
                          L"Logon attempt for locked account " + who + L", " +
                              std::to_wstring(info.minutesLeft) + L" minutes left. " + session + L".");
            swprintf_s(lockText, L"Too many wrong codes. Try again in %u minutes.", info.minutesLeft);
            message = lockText;
            break;

        case tac::OtpResult::NotEnrolled:
            tac::LogEvent(EVENTLOG_WARNING_TYPE, tac::EVT_NOT_ENROLLED,
                          L"Logon attempt for " + who + L", which is not enrolled. " + session + L".");
            break;

        default:
            tac::LogEvent(EVENTLOG_ERROR_TYPE, tac::EVT_ERROR,
                          L"Could not lock, read or write the 2FA state of " + who +
                              L". The logon was refused. " + session + L".");
            break;
        }

        // The password is proven, keep it; only the code has to be typed again.
        _ResetField(TFI_OTP);
        SHStrDupW(message, ppwszOptionalStatusText);
        *pcpsiOptionalStatusIcon = CPSI_ERROR;
        return S_OK;
    }

    // Not "logon succeeded" yet: LSA has the last word, see ReportResult.
    tac::LogEvent(EVENTLOG_INFORMATION_TYPE, tac::EVT_CODE_OK,
                  L"Password and one-time code accepted for " + who +
                      L". The logon goes to LSA now. " + session + L".");

    // --- the ordinary password logon, once both factors are valid ---
    // Always log on as COMPUTERNAME\user. This is a local-account demo, so we
    // never authenticate a domain the user might have typed. Built before the
    // password is copied: from here on nothing may throw while a copy exists.
    std::wstring qualified = std::wstring(computer) + L"\\" + bare;

    PWSTR pwzPassword = nullptr;
    HRESULT hr = ProtectIfNecessaryAndCopyPassword(_rgFieldStrings[TFI_PASSWORD], _cpus, &pwzPassword);

    WCHAR domain[64] = {};
    WCHAR user[256]  = {};
    if (SUCCEEDED(hr))
        hr = SplitDomainAndUsername(qualified.c_str(), domain, ARRAYSIZE(domain),
                                    user, ARRAYSIZE(user));

    // Zeroed here and not only inside KerbInteractiveUnlockLogonInit: every use
    // below sits behind SUCCEEDED(hr), so today it can never be read uninitialised.
    // One edit that moves a call out of that chain would hand Pack a stack full
    // of garbage pointers, and this costs nothing.
    KERB_INTERACTIVE_UNLOCK_LOGON kiul = {};
    if (SUCCEEDED(hr))
        hr = KerbInteractiveUnlockLogonInit(domain, user, pwzPassword, _cpus, &kiul);
    if (SUCCEEDED(hr))
        hr = KerbInteractiveUnlockLogonPack(kiul, &pcpcs->rgbSerialization, &pcpcs->cbSerialization);
    if (SUCCEEDED(hr))
    {
        ULONG ulAuthPackage = 0;
        hr = RetrieveNegotiateAuthPackage(&ulAuthPackage);
        if (SUCCEEDED(hr))
        {
            pcpcs->ulAuthenticationPackage = ulAuthPackage;
            pcpcs->clsidCredentialProvider = CLSID_CTacProvider;
            *pcpgsr = CPGSR_RETURN_CREDENTIAL_FINISHED;
        }
    }

    if (FAILED(hr) && pcpcs->rgbSerialization)
    {
        SecureZeroMemory(pcpcs->rgbSerialization, pcpcs->cbSerialization);
        CoTaskMemFree(pcpcs->rgbSerialization);
        pcpcs->rgbSerialization = nullptr;
        pcpcs->cbSerialization  = 0;
    }
    if (pwzPassword)
    {
        SecureZeroMemory(pwzPassword, wcslen(pwzPassword) * sizeof(WCHAR));
        CoTaskMemFree(pwzPassword);
    }
    return hr;
}

// LSA reports the result of the logon here. Password and code are cleared in
// every case: after a success they are not needed anymore, and after a failure
// the code is used up anyway. The password was already checked before the
// code, so a failure here is rare (the password was changed in between, or a
// logon right is missing). This is also where the final outcome is logged.
IFACEMETHODIMP CTacCredential::ReportResult(NTSTATUS ntsStatus, NTSTATUS ntsSubstatus,
                                            PWSTR* ppwszOptionalStatusText,
                                            CREDENTIAL_PROVIDER_STATUS_ICON* pcpsiOptionalStatusIcon)
{
    if (!ppwszOptionalStatusText || !pcpsiOptionalStatusIcon)
        return E_POINTER;
    *ppwszOptionalStatusText = nullptr;
    *pcpsiOptionalStatusIcon = CPSI_NONE;

    _ResetField(TFI_PASSWORD);
    _ResetField(TFI_OTP);

    // The name as it stands in the username field, cut to a sane length.
    wchar_t text[192];
    swprintf_s(text, L"Logon of '%.64s' finished: status 0x%08lX, substatus 0x%08lX.",
               _rgFieldStrings[TFI_USERNAME] ? _rgFieldStrings[TFI_USERNAME] : L"",
               static_cast<unsigned long>(ntsStatus), static_cast<unsigned long>(ntsSubstatus));
    try
    {
        tac::LogEvent(ntsStatus == 0 ? EVENTLOG_INFORMATION_TYPE : EVENTLOG_WARNING_TYPE,
                      tac::EVT_LOGON_RESULT, text);
    }
    catch (...)
    {
        // Logging is best effort; never let it reach LogonUI.
    }

    if (ntsStatus == STATUS_LOGON_FAILURE)
    {
        SHStrDupW(L"Incorrect password.", ppwszOptionalStatusText);
        *pcpsiOptionalStatusIcon = CPSI_ERROR;
    }
    return S_OK;
}

GetSerialization is the only place where we can stop the logon. LogonUI calls it when the user clicks submit. This is the order of the checks, with the message on the screen and the event for each way out:

The password is checked first, then the code. If one of them is wrong, the function returns S_OK with CPGSR_NO_CREDENTIAL_NOT_FINISHED and a message. The tile stays and the user can try again. If I returned an error instead, the tile would just show a generic error.

My first version did it the other way around. It checked the code first and never looked at the password. The idea: without a valid code the password is never sent, so nobody can test passwords with our tile. A code audit showed the problem. Every wrong code counts toward the lock from the next chapter. So anyone at the logon screen could type five random codes for any account and lock it. Without knowing a single password. Over RDP it was even easier. The RDP tile lets you type a different username, after you got in with your own account through NLA.

So now CheckPassword asks Windows first, with LogonUser. Only if the password is correct, the code is checked and counted. Testing passwords on our tile now works exactly like on the normal password tile, and Windows counts the wrong ones with its normal lockout policy. So set one. New installs of Windows 11 22H2 and later come with a lockout policy, older installs and upgrades often don't. Wrong codes are counted by our own lock. But only someone who already has the password can type them.

LogonUser gets LOGON32_LOGON_INTERACTIVE and not a network logon, because Part 2 denies network logons for enrolled accounts. Some errors only come back after Windows accepted the password. For example if the account may not log on locally (an RDP-only user on a server), if the password has to be changed, or if a blank password is not allowed outside the console. For these the password counts as correct, and the real logon at the end decides. A password that came encrypted over RDP is decrypted into a temporary copy for this check and wiped right after. The price for all of this: every logon creates one more logon event (4624, type 2, LogonUI.exe) in the Security log.

A wrong code still never reaches LSA as a failed logon, so the Windows lockout policy doesn't count it. That's why the demo has its own counter: after 5 wrong codes in a row the account is locked. This is the next chapter.

Users without a secret cannot log in. Together with the filter this means that users who are not enrolled cannot log in interactively at all. So enroll every account you need before you enable the filter. Including a break-glass admin.

Same message for almost all errors. A wrong password and an unknown account show the same text. After a correct password, "not enrolled", "wrong code" and "code already used" also show the same text. The only exception is the lock. If the account is locked, the tile says so and how many minutes are left. Without this message a user would keep typing correct codes and wonder why nothing works. The price: someone who knows the password and types 5 wrong codes learns that the account is enrolled. But that person already has the first factor, so I think this is okay.

The account is always a local account. On user tiles the name comes from Windows (PC\alice), on the "Other user" tile it is typed. In both cases the provider takes only the bare name, resolves COMPUTERNAME\name to a SID, checks the code for this SID and logs in as COMPUTERNAME\name. So the account that is checked is always the same local account that is logged in. In my first version of the user tiles I forgot the check for local accounts. On a domain joined machine the tile for CONTOSO\alice would have accepted the code of the local alice. That's why there are two checks now: only local accounts get a tile, and the logon always uses the computer name.

Tile and account have to match. A user tile knows its SID (_pszSid). Before anything is checked, the name from the tile is resolved to a SID and both are compared. If they are not the same, neither the password nor the code is checked. Normally this can't happen, because the name comes from Windows. But it costs one line and closes the door if something ever puts a different name into the field.

Every attempt is logged. Wrong password, wrong code, used code, lock, account not enrolled, accepted, and the final result from LSA. Each event has the account, the SID and where the attempt came from: console, or RDP with the address the client reports. The code and the password are never logged. More about this in the chapter about logging.

The rest is a normal logon. When the code is correct, the provider does the same as the normal password tile. It fills a KERB_INTERACTIVE_UNLOCK_LOGON with domain, username and the password, encrypted with CredProtect, packs it into one buffer and sends it to the Negotiate package. For a local account this ends up in MSV1_0 and the local SAM. I don't change the actual authentication. I just don't start it until password and code are correct. How the packing works is explained in the chapter about the helper functions.

Cleanup. Password and code are deleted from memory when the tile loses focus, and after every logon result (ReportResult), successful or not. After a wrong code only the code field is emptied: the password is proven, so the user doesn't have to type it again. If something goes wrong after the password was packed, the buffer is overwritten and freed. For RDP the provider deletes its copy of the password as soon as the tile has its own.

Nothing undefined goes back to LogonUI. GetSerialization sets all its outputs first, before it does anything else: the response, the serialization, the status text and the icon. The Microsoft sample does the same. My first version didn't set the status text on success, so LogonUI could read a garbage pointer there. The real work is now in _GetSerializationImpl. GetSerialization catches every C++ exception around it, so a std::bad_alloc becomes an error code and not a crash on the logon screen.

Rate limiting and replay protection

The code check itself is in ValidateTotp. Two things around it were missing in my first version: a code could be used again within 90 seconds, and there was no limit for wrong codes.

The second one sounds harmless, so let's do the math. With a window of one step before and after, 3 of the 1'000'000 possible codes are valid at any time. One guess hits with a probability of 3 in a million. After n guesses the chance for at least one hit is:

P(n) = 1 - (1 - 3/1'000'000)^n

P = 50%  after about 231'000 guesses
P = 90%  after about 768'000 guesses

That sounds like a lot. But nobody types them by hand. A cheap USB device that acts as a keyboard types around 2 codes per second into the logon screen. Over RDP it's even easier to automate. 231'000 guesses at 2 per second are about 32 hours. That's one weekend. And the Windows lockout policy never sees any of these guesses. The password is correct, only the code is wrong, and for Windows that is not a failed logon. There isn't even a failure event in the Security log.

Of course the attacker needs the password for this. But that is exactly the situation 2FA is built for.

The state per account

For both problems the provider has to remember something per account. I store three values in one small structure (it's in store.h):

C++
struct UserState
{
    uint64_t lastStep;      // last accepted TOTP time step, 0 = none yet
    uint32_t failures;      // wrong codes in a row
    uint32_t reserved;
    uint64_t lockedUntil;   // unix time, 0 = not locked
};

It is stored as REG_BINARY under HKLM\SOFTWARE\TheAdminCafe\2FA\State\<SID>, with the same ACL as the secrets: only SYSTEM and administrators. LogonUI runs as SYSTEM, so it can write there. A normal user can't reset their own counter. The static_assert makes sure that the structure has exactly 24 bytes, because it goes into the registry as it is.

The lock

After 5 wrong codes in a row the account is locked. Every further wrong code doubles the time: 5, 10, 20, 40 and then always 60 minutes. A correct code resets the counter to zero.

What does this do to the attack? The first 5 guesses are free. The next 4 need 75 minutes, after that there is one guess per hour. That's about 24 guesses per day. For 231'000 guesses the attacker needs about 26 years.

Why doubling and not a fixed lock? With a fixed 5 minutes it would be 288 guesses per day, and 50% after about 800 days. That's long, but there is no reason to be generous. A normal user who mistypes twice never sees the lock anyway.

verify.h:

C++
#pragma once
#include <windows.h>
#include <cstdint>
#include <string>

namespace tac
{
    // After this many wrong codes in a row the account is locked.
    const uint32_t kFreeFailures = 5;

    enum class OtpResult
    {
        Ok,           // code correct, time step recorded
        Wrong,        // wrong code (counted)
        Replayed,     // correct code, but its time step was already used (counted)
        LockedOut,    // too many wrong codes, the code was not even checked
        NotEnrolled,  // no secret for this SID
        Error,        // state could not be read or written, fail closed
    };

    struct OtpInfo
    {
        uint32_t failures;      // wrong codes in a row, after this attempt
        uint32_t minutesLeft;   // lock time left, if locked
        uint64_t step;          // the accepted time step, if Ok
    };

    // Lock time after the n-th wrong code in a row: 0 up to kFreeFailures - 1,
    // then 5, 10, 20, 40 and at most 60 minutes.
    uint32_t LockMinutesFor(uint32_t failures);

    // Checks the code for the account and updates its state (replay
    // protection and rate limiting). `now` is the unix time. Holds the
    // StateLock for the whole load-check-save; if it cannot be taken, the
    // result is Error.
    //
    // Call this only AFTER the password was verified: every wrong code counts
    // toward the lock, so checking codes for a password nobody has proven would
    // let anyone at the logon screen lock any enrolled account out.
    OtpResult VerifyOtp(const std::wstring& sid, const std::wstring& code,
                        uint64_t now, OtpInfo& info);
}

verify.cpp:

C++
#include "verify.h"
#include "store.h"
#include "totp.h"
#include "statelock.h"
#include <vector>

namespace tac
{
    uint32_t LockMinutesFor(uint32_t failures)
    {
        if (failures < kFreeFailures)
            return 0;
        uint32_t doublings = failures - kFreeFailures;   // 0, 1, 2, ...
        if (doublings > 4)
            doublings = 4;                               // 5 << 4 = 80, capped below
        uint32_t minutes = 5u << doublings;
        return minutes > 60 ? 60 : minutes;
    }

    static uint32_t MinutesLeft(uint64_t lockedUntil, uint64_t now)
    {
        uint64_t seconds = lockedUntil > now ? lockedUntil - now : 0;
        return static_cast<uint32_t>((seconds + 59) / 60);   // round up
    }

    OtpResult VerifyOtp(const std::wstring& sid, const std::wstring& code,
                        uint64_t now, OtpInfo& info)
    {
        info = {};

        // Load, check and save as one step. Two logon screens checking the same
        // account at the same moment would otherwise both accept one code.
        // No lock, no check: fail closed.
        StateLock lock;
        if (!lock.Held())
            return OtpResult::Error;

        std::vector<BYTE> key;
        if (!LoadSecretKey(sid, key))
            return OtpResult::NotEnrolled;

        UserState state;
        if (!LoadState(sid, state))
        {
            SecureZeroMemory(key.data(), key.size());
            return OtpResult::Error;
        }
        info.failures = state.failures;

        // While the account is locked, the code is not checked at all.
        // Otherwise the lock would only hide the result, not stop the guessing.
        if (state.lockedUntil > now)
        {
            SecureZeroMemory(key.data(), key.size());
            info.minutesLeft = MinutesLeft(state.lockedUntil, now);
            return OtpResult::LockedOut;
        }

        uint64_t matched = 0;
        bool ok = ValidateTotp(key, code, now, state.lastStep, matched);

        // Only to write the right event: was it a correct code that was
        // already used? It still counts as a failure.
        uint64_t ignored = 0;
        bool replayed = !ok && state.lastStep != 0 && ValidateTotp(key, code, now, 0, ignored);
        SecureZeroMemory(key.data(), key.size());

        if (ok)
        {
            state.lastStep    = matched;
            state.failures    = 0;
            state.lockedUntil = 0;
            info.step         = matched;

            // If the step cannot be saved, the same code would work again.
            // So this is an error and not a success.
            return SaveState(sid, state) ? OtpResult::Ok : OtpResult::Error;
        }

        if (state.failures < 0xFFFFFFFFu)
            ++state.failures;
        uint32_t minutes = LockMinutesFor(state.failures);
        state.lockedUntil = minutes ? now + static_cast<uint64_t>(minutes) * 60 : 0;

        info.failures    = state.failures;
        info.minutesLeft = minutes;

        if (!SaveState(sid, state))
            return OtpResult::Error;
        return replayed ? OtpResult::Replayed : OtpResult::Wrong;
    }
}

While the account is locked, the code is not checked at all. If you check the code and only hide the result, the attacker can still guess. He just doesn't see the answer until the lock is over, and then he tries the codes again that are still valid. Here a locked account never gets to ValidateTotp. Attempts during the lock are not counted either. Otherwise every attempt would make the lock longer and the user would never get back in.

An already used code counts as a wrong code. It is a correct code, but someone tries to use it a second time. That is exactly what should not happen. To write the right event I check a second time without lastStep. This only decides between "wrong" and "already used" in the log. It never lets anything through.

Fail closed. If the state can't be read or written, the logon is refused. On success this is critical: if the used step can't be saved, the same code would work again. On failure it's just as important, otherwise an attacker who makes the write fail would have unlimited guesses. The only thing that breaks the write is a broken registry, and then you have other problems. The same goes for the lock below: if it can't be taken within 5 seconds, the logon is refused.

The step is burned before LSA has the last word. The state is saved in GetSerialization, before the logon goes to LSA. At this point LogonUser has already checked the password, so the real logon almost never fails anymore. If it does, for example because the password was changed in exactly that second, the code is gone and the user waits for the next one. The other way around would be worse. The provider only learns the result in ReportResult, and between these two calls a second session could use the same code.

Denial of service. To lock an account you now need its password, because a code is only counted after the password was correct. My first version counted before, so anyone at the tile could lock any enrolled account. Someone with the password can still lock it with 5 wrong codes. That's fine, then the second factor does exactly its job. My lock also hits the built-in Administrator, at the console too. Windows can lock that account since the October 2022 updates ("Allow Administrator account lockout", on by default on new Windows 11 22H2 installs), but only for network logons and RDP. At the console Windows never locks it. If your break-glass account is locked, another admin runs enroll.exe /unlock, or you wait at most 60 minutes. Leave NLA on for RDP anyway. Without it, anybody who reaches port 3389 gets a logon screen to test passwords.

The clock. The lock uses the unix time, just like TOTP. Someone who sets the clock forward in the BIOS skips the lock. But that is someone with physical access, and for them there are easier ways. See "Attacks that never touch the tile".

Parallel sessions. On a server with RDP there can be several LogonUI processes at the same time, one per session. My first version read and wrote the state without a lock between these processes. Two sessions could read the same state at the same moment, both accept the same code and both get extra guesses past the lock. The audit showed exactly that. Now VerifyOtp holds a StateLock for the whole read, check and write.

A named mutex looks like the easy fix. But a normal user can create a mutex with the same name first and then block every logon. So the lock is a file: state.lock, next to the DLL in C:\Program Files\TheAdminCafe. It is opened without sharing. As long as one process has it open, every other open fails. That's the whole lock. Only administrators can create files in this folder, and the file gets an ACL for SYSTEM and administrators only. So nobody else can open it, and nobody else can hold it. If a process crashes, Windows closes the handle and the lock is free again.

statelock.h:

C++
#pragma once
#include <windows.h>

namespace tac
{
    // Serializes every read-modify-write of the per-account state across
    // processes. Without it, two LogonUI instances (two RDP sessions, or the
    // console and an RDP session) can both read the same state, both accept
    // the same code, and both get a full set of guesses per lock period.
    //
    // The lock is an exclusively opened file next to the module (normally
    // C:\Program Files\TheAdminCafe\state.lock). A named mutex would be simpler,
    // but any logged-on user can create a mutex under Global\ first and hold it,
    // which would block every 2FA logon on the machine. The install folder is
    // writable only by administrators, and the file is created with an ACL for
    // SYSTEM and Administrators only, so nobody else can open, let alone hold it.
    //
    // The OS closes the handle when a process dies, so a crash never leaves a
    // stale lock behind.
    class StateLock
    {
    public:
        explicit StateLock(DWORD timeoutMs = 5000);
        ~StateLock();

        bool Held() const { return _h != INVALID_HANDLE_VALUE; }

        StateLock(const StateLock&) = delete;
        StateLock& operator=(const StateLock&) = delete;

    private:
        HANDLE _h;
    };
}

statelock.cpp:

C++
#include "statelock.h"
#include <sddl.h>
#include <string>

#pragma comment(lib, "advapi32.lib")

namespace tac
{
    // Full path of "state.lock" in the folder of the module this code is
    // linked into (TacProvider.dll in LogonUI, enroll.exe on the console).
    static bool LockFilePath(std::wstring& path)
    {
        HMODULE self = nullptr;
        if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
                                GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
                                reinterpret_cast<LPCWSTR>(&LockFilePath), &self))
            return false;

        WCHAR buf[MAX_PATH];
        DWORD cch = GetModuleFileNameW(self, buf, ARRAYSIZE(buf));
        if (cch == 0 || cch >= ARRAYSIZE(buf))
            return false;

        path.assign(buf, cch);
        size_t slash = path.find_last_of(L'\\');
        if (slash == std::wstring::npos)
            return false;
        path.erase(slash + 1);
        path += L"state.lock";
        return true;
    }

    StateLock::StateLock(DWORD timeoutMs)
        : _h(INVALID_HANDLE_VALUE)
    {
        std::wstring path;
        if (!LockFilePath(path))
            return;

        // SYSTEM and Administrators only, not inherited from the folder.
        PSECURITY_DESCRIPTOR psd = nullptr;
        if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
                L"D:P(A;;FA;;;SY)(A;;FA;;;BA)", SDDL_REVISION_1, &psd, nullptr))
            return;
        SECURITY_ATTRIBUTES sa = { sizeof(sa), psd, FALSE };

        const ULONGLONG deadline = GetTickCount64() + timeoutMs;
        for (;;)
        {
            // Share mode 0: while we hold the handle, every other open fails
            // with ERROR_SHARING_VIOLATION. That is the whole lock.
            _h = CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, &sa,
                             OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
            if (_h != INVALID_HANDLE_VALUE)
                break;
            if (GetLastError() != ERROR_SHARING_VIOLATION || GetTickCount64() >= deadline)
                break;
            Sleep(20);
        }

        LocalFree(psd);
    }

    StateLock::~StateLock()
    {
        if (_h != INVALID_HANDLE_VALUE)
            CloseHandle(_h);
    }
}

Logging

The counter stops a brute-force attack, but you still want to know that it happened. A wrong code never reaches LSA, so there is no event 4625 in the Security log. The provider has to write its own events.

I write them into the Application log with the source TheAdminCafe 2FA:

IDTypeWhen
100InformationPassword and code accepted, the logon goes to LSA
101WarningWrong code
102WarningCode was already used
103WarningAttempt while the account is locked
104ErrorThis wrong code locked the account
105WarningPassword correct, but the account is not enrolled
106ErrorState could not be locked, read or written, logon refused
107WarningPassword refused or account unknown, the code was not checked
108Information / WarningFinal result of the logon from LSA (ReportResult)

Every event has the account, the SID, the number of wrong codes in a row and where the attempt came from. For RDP this is the IP address that the client reports. That's a hint for the log, not proof. The client can put anything there. The code itself is never logged, not even a wrong one. A wrong code is often just a typo of the right one.

Event 100 doesn't mean "logon succeeded" anymore. It only says that both factors were correct and the logon goes to LSA. If LSA accepted it, you see in event 108 from ReportResult. My first version logged a success before Windows had decided.

eventlog.h:

C++
#pragma once
#include <windows.h>
#include <string>

namespace tac
{
    // Event IDs in the Application log, source "TheAdminCafe 2FA".
    enum : DWORD
    {
        EVT_CODE_OK       = 100,
        EVT_CODE_WRONG    = 101,
        EVT_CODE_REPLAYED = 102,
        EVT_LOCKED_OUT    = 103,   // attempt while the account was locked
        EVT_LOCK_STARTED  = 104,   // this attempt locked the account
        EVT_NOT_ENROLLED  = 105,
        EVT_ERROR         = 106,
        EVT_PASSWORD_BAD  = 107,   // password refused before the code was checked
        EVT_LOGON_RESULT  = 108,   // final result from LSA (ReportResult)
    };

    // Writes one line of text. Never throws, never shows UI. If the event log
    // is not reachable, the event is lost and the logon goes on.
    void LogEvent(WORD type, DWORD id, const std::wstring& text);

    // "console, session 1" or "remote, client reports 192.168.1.50, session 3".
    // The remote address is self-reported by the RDP client.
    std::wstring DescribeSession();
}

eventlog.cpp:

C++
#include "eventlog.h"
#include <wtsapi32.h>
#include <cstdio>

#pragma comment(lib, "advapi32.lib")
#pragma comment(lib, "user32.lib")
#pragma comment(lib, "wtsapi32.lib")

namespace tac
{
    static const wchar_t kSource[] = L"TheAdminCafe 2FA";

    void LogEvent(WORD type, DWORD id, const std::wstring& text)
    {
        HANDLE hLog = RegisterEventSourceW(nullptr, kSource);
        if (!hLog)
            return;

        LPCWSTR strings[1] = { text.c_str() };
        ReportEventW(hLog, type, 0, id, nullptr, 1, 0, strings, nullptr);
        DeregisterEventSource(hLog);
    }

    std::wstring DescribeSession()
    {
        DWORD sessionId = 0;
        ProcessIdToSessionId(GetCurrentProcessId(), &sessionId);

        wchar_t buf[96];
        if (!GetSystemMetrics(SM_REMOTESESSION))
        {
            swprintf_s(buf, L"console, session %lu", sessionId);
            return buf;
        }

        // For RDP, add the client address. LogonUI runs in the session of the
        // connection, so WTS_CURRENT_SESSION is the right one. This is the
        // address the RDP CLIENT reports about itself, not the address of the
        // TCP connection, so it is a hint for the log and not evidence.
        std::wstring from = L"unknown address";
        WTS_CLIENT_ADDRESS* addr = nullptr;
        DWORD cb = 0;
        if (WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, WTS_CURRENT_SESSION,
                                        WTSClientAddress, reinterpret_cast<LPWSTR*>(&addr), &cb) &&
            addr && cb >= sizeof(*addr))
        {
            if (addr->AddressFamily == AF_INET)
            {
                // For IPv4 the address starts at offset 2 of the byte array.
                // 16 would be exactly enough for "255.255.255.255"; leave room.
                wchar_t ip[20];
                swprintf_s(ip, L"%u.%u.%u.%u", addr->Address[2], addr->Address[3],
                           addr->Address[4], addr->Address[5]);
                from = ip;
            }
            else
            {
                from = L"non-IPv4 address";
            }
        }
        if (addr)
            WTSFreeMemory(addr);

        swprintf_s(buf, L"remote, client reports %s, session %lu", from.c_str(), sessionId);
        return buf;
    }
}

Some details:

  • RegisterEventSourceW opens the log for every event and DeregisterEventSource closes it again. That is a bit slower, but LogonUI loads and unloads our DLL whenever it wants. A handle that stays open is one more thing that can go wrong.
  • Logging must never block the logon. If the event log isn't reachable, the event is lost. LogEvent doesn't return an error, on purpose.
  • SM_REMOTESESSION tells me if LogonUI runs in an RDP session. LogonUI runs in the session of the connection, so WTS_CURRENT_SESSION is the right session to ask for the client address. For IPv4 the address starts at byte 2 of the array. That's how WTS_CLIENT_ADDRESS is defined.

Windows wants a message file for every event source. Without it Event Viewer shows "The description for Event ID 101 from source TheAdminCafe 2FA cannot be found" and our text only appears further down. I didn't want to build a message DLL just for this. Windows comes with EventCreate.exe, and it has a message table that prints the text (%1) for the IDs 1 to 1000. So register.reg registers our source with EventCreate.exe as the message file. That's why my IDs are between 100 and 108. Part 2 adds 200 to 210.

To see the events:

PowerShell
Get-WinEvent -FilterHashtable @{ LogName = 'Application'; ProviderName = 'TheAdminCafe 2FA' } -MaxEvents 20 |
    Format-Table TimeCreated, Id, LevelDisplayName, Message -Wrap

If you forward your logs to a SIEM, alert on ID 104. A locked account is either a user who has a bad day, or somebody who knows the password. Many 107 in a row are somebody guessing passwords.

The filter

This is the mistake that I see in most 2FA tutorials. If you register a Credential Provider, it only adds a tile. The password tile, the PIN tile and Windows Hello are still there. Anyone with the password just chooses the normal tile and never sees the code field.

So we have to hide all other providers on the logon and unlock screen. This is what the filter does.

CTacFilter.h:

C++
#pragma once
#include <windows.h>
#include <credentialprovider.h>
#include "dll.h"

// Hides every credential provider except ours on the logon and unlock screens.
// A provider only adds a tile; the filter is what makes the second factor
// unavoidable, by removing the password, PIN and Windows Hello tiles.
class CTacFilter : public ICredentialProviderFilter
{
public:
    CTacFilter() : _cRef(1) { DllAddRef(); }

    IFACEMETHODIMP_(ULONG) AddRef();
    IFACEMETHODIMP_(ULONG) Release();
    IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv);

    IFACEMETHODIMP Filter(CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus, DWORD dwFlags,
                          GUID* rgclsidProviders, BOOL* rgbAllow, DWORD cProviders);
    IFACEMETHODIMP UpdateRemoteCredential(
        const CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcsIn,
        CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcsOut);

    friend HRESULT CTacFilter_CreateInstance(REFIID riid, void** ppv);

private:
    virtual ~CTacFilter() { DllRelease(); }

    // At least one stored secret, or a store that cannot be read.
    static bool AnyEnrollment();
    LONG _cRef;
};

CTacFilter.cpp:

C++
#include "CTacFilter.h"
#include "guid.h"
#include "dll.h"
#include "store.h"
#include <ntsecapi.h>
#include <new>

IFACEMETHODIMP_(ULONG) CTacFilter::AddRef()
{
    return InterlockedIncrement(&_cRef);
}

IFACEMETHODIMP_(ULONG) CTacFilter::Release()
{
    LONG cRef = InterlockedDecrement(&_cRef);
    if (cRef == 0)
        delete this;
    return cRef;
}

IFACEMETHODIMP CTacFilter::QueryInterface(REFIID riid, void** ppv)
{
    if (!ppv)
        return E_POINTER;
    if (IsEqualIID(riid, IID_IUnknown) || IsEqualIID(riid, __uuidof(ICredentialProviderFilter)))
    {
        *ppv = static_cast<ICredentialProviderFilter*>(this);
        AddRef();
        return S_OK;
    }
    *ppv = nullptr;
    return E_NOINTERFACE;
}

// Allow only our provider on the logon and unlock screens, so the code cannot
// be skipped by picking the password or PIN tile. That deliberately goes
// against Microsoft's advice to always keep one of their providers: with one
// left visible, the second factor would be optional. The recovery story is
// therefore:
//   - If TacProvider.dll cannot be loaded, this filter (same DLL) cannot be
//     created either, and LogonUI skips it - every provider shows again.
//     Worth confirming once in the VM by renaming the DLL.
//   - While no account is enrolled at all (filter imported too early, or after
//     `enroll /purge`), nothing is hidden.
//   - Our provider always offers an "Other user" tile at logon, for accounts
//     Windows does not list (a hidden break-glass admin).
//   - Otherwise: WinRE, delete the filter key offline (see README).
// Other scenarios (CredUI, change password, PLAP) are left alone, or we would
// break unrelated prompts.
IFACEMETHODIMP CTacFilter::Filter(CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus, DWORD,
                                  GUID* rgclsidProviders, BOOL* rgbAllow, DWORD cProviders)
{
    if (cpus != CPUS_LOGON && cpus != CPUS_UNLOCK_WORKSTATION)
        return S_OK;
    if (!rgclsidProviders || !rgbAllow)
        return E_POINTER;

    // Nothing enrolled: hiding the other tiles would only lock everyone out.
    // A store that cannot be read is NOT treated as empty - filter as usual.
    // QueryAnyEnrollment counts stored secrets, not registry values, so a
    // leftover value of another kind cannot make us hide every way in while
    // nothing can actually authenticate.
    if (!AnyEnrollment())
        return S_OK;

    for (DWORD i = 0; i < cProviders; ++i)
        rgbAllow[i] = IsEqualGUID(rgclsidProviders[i], CLSID_CTacProvider);
    return S_OK;
}

// Is at least one account enrolled? A store that cannot be read counts as
// "yes", so an unreadable store does not quietly switch the second factor off.
bool CTacFilter::AnyEnrollment()
{
    bool any = false;
    if (!tac::QueryAnyEnrollment(any))
        return true;
    return any;
}

// RDP with NLA delivers the credential before a tile exists. We forward it and
// set the target provider to ourselves; copying the incoming CLSID would send
// it to a provider we just filtered out, and remote logon would break.
//
// Only while we are actually filtering, though: with nothing enrolled, Filter()
// leaves every other provider visible, and taking the credential here would
// hand it to a tile that cannot authenticate anybody. Then it belongs to
// whoever it was addressed to.
IFACEMETHODIMP CTacFilter::UpdateRemoteCredential(
    const CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcsIn,
    CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcsOut)
{
    if (!pcpcsOut)
        return E_POINTER;
    ZeroMemory(pcpcsOut, sizeof(*pcpcsOut));
    if (!pcpcsIn || pcpcsIn->cbSerialization == 0 || !pcpcsIn->rgbSerialization)
        return E_NOTIMPL;
    if (!AnyEnrollment())
        return E_NOTIMPL;

    // RDP can forward other logon types too, a smartcard logon for example.
    // Only a password logon has the layout our tile unpacks, so anything else
    // stays with the provider it was addressed to instead of being taken and
    // then refused. MessageType is the first member of the packed
    // KERB_INTERACTIVE_UNLOCK_LOGON.
    if (pcpcsIn->cbSerialization < sizeof(KERB_INTERACTIVE_UNLOCK_LOGON))
        return E_NOTIMPL;
    const KERB_LOGON_SUBMIT_TYPE type =
        reinterpret_cast<const KERB_INTERACTIVE_UNLOCK_LOGON*>(
            pcpcsIn->rgbSerialization)->Logon.MessageType;
    if (type != KerbInteractiveLogon && type != KerbWorkstationUnlockLogon)
        return E_NOTIMPL;

    pcpcsOut->rgbSerialization =
        static_cast<BYTE*>(CoTaskMemAlloc(pcpcsIn->cbSerialization));
    if (!pcpcsOut->rgbSerialization)
        return E_OUTOFMEMORY;

    CopyMemory(pcpcsOut->rgbSerialization, pcpcsIn->rgbSerialization, pcpcsIn->cbSerialization);
    pcpcsOut->cbSerialization         = pcpcsIn->cbSerialization;
    pcpcsOut->ulAuthenticationPackage = pcpcsIn->ulAuthenticationPackage;
    pcpcsOut->clsidCredentialProvider = CLSID_CTacProvider;
    return S_OK;
}

HRESULT CTacFilter_CreateInstance(REFIID riid, void** ppv)
{
    CTacFilter* filter = new (std::nothrow) CTacFilter();
    if (!filter)
        return E_OUTOFMEMORY;
    HRESULT hr = filter->QueryInterface(riid, ppv);
    filter->Release();
    return hr;
}

Filter only hides providers during logon and unlock. If I also hid them for CPUS_CREDUI, UAC prompts would stop working because our provider does not support that scenario.

The filter hides everything else on purpose, and that goes against the advice from Microsoft. Their documentation says a filter should always leave one of their providers, so a broken provider can't lock you out. But if the password tile is still there, the code is optional, and then the whole project makes no sense. So I kept it and built the way back around it:

  • As long as no account is enrolled, the filter hides nothing. QueryAnyEnrollment counts the stored secrets, and only real ones. My first version counted every value under the key. So importing the filter too early, or after enroll.exe /purge, can't lock you out. If the store exists but can't be read, it doesn't count as empty and the filter works as usual. One case is still yours to watch: if you delete an enrolled account without /remove, its secret still counts. enroll.exe /list shows it, and /remove with its SID clears it.
  • The provider always offers an "Other user" tile at logon, for accounts that Windows doesn't list.
  • If TacProvider.dll can't be loaded, the filter can't be created either, because it's in the same DLL. Then LogonUI shows all providers again. Rename the DLL once in your VM and check this yourself.
  • For everything else there is WinRE, see "Debugging and what to do if you are locked out".

UpdateRemoteCredential is for RDP. With Network Level Authentication (NLA) the RDP client sends the credentials via CredSSP before any tile is shown. The filter forwards them. The CLSID in the output decides which provider gets them, so I set it to our provider. If you just copy the CLSID from the input, the credentials go to a provider that is hidden by the filter and RDP no longer works.

Two exceptions. While no account is enrolled, Filter hides nothing, and our tile couldn't log anybody on. Then the filter leaves the credential with the provider it was sent to. And it only takes password logons. A smartcard logon over RDP stays where it was going. My first version took everything and then refused what it couldn't unpack.

The whole RDP path, step by step:

Step 1 is important for Part 2: NLA is a network logon, so anything that refuses network logons for an enrolled account also refuses RDP with NLA.

Some things about RDP you should know:

  • NLA checks the password before our tile. An attacker who knows the password can find out via RDP that it is correct. But without the code they don't get a session.
  • Depending on the RDP client the password can arrive encrypted (CredProtect). The serialization for LSA can handle this. For the password check with LogonUser the tile decrypts a copy first (CopyUnprotectedPassword). I have not tested this part as much as the rest, so please test it in your environment.
  • The filled in password is deleted when the tile loses focus (SetDeselected), like every other password. If you click somewhere else and back, you have to type it again.

Helper functions and COM

helpers.cpp is a shortened version of the helper functions from the Microsoft Credential Provider sample (Windows-classic-samples, Samples/CredentialProvider).

The most important function is KerbInteractiveUnlockLogonPack. LSA runs in another process (lsass.exe) and can't use pointers from LogonUI. So the blob has to contain everything itself. The function copies the three strings (domain, user, password) directly behind the structure and replaces every pointer with an offset from the start of the buffer. KerbInteractiveUnlockLogonUnpackInPlace does the opposite for RDP. It also checks the offsets, because this data comes from outside.

helpers.h:

C++
#pragma once
#include <windows.h>
#include <credentialprovider.h>
#include <ntsecapi.h>

// Trimmed versions of the helpers from Microsoft's credential provider sample
// (Windows-classic-samples, Samples/CredentialProvider).

HRESULT SplitDomainAndUsername(PCWSTR pszQualifiedUserName,
                               PWSTR pszDomain, int cchDomain,
                               PWSTR pszUsername, int cchUsername);

// Copies the password for the serialization and, for logon and unlock,
// encrypts the copy with CredProtectW as Microsoft's sample does. A password
// that is already protected (RDP can forward one) is copied as it is.
HRESULT ProtectIfNecessaryAndCopyPassword(PCWSTR pwzPassword,
                                          CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
                                          PWSTR* ppwzProtectedPassword);

// The opposite, for code that needs the plain text (the LogonUser password
// check): a protected password is decrypted, a plain one is copied. The caller
// wipes and frees the result with CoTaskMemFree.
HRESULT CopyUnprotectedPassword(PCWSTR pwzPassword, PWSTR* ppwzPlain);

// Fills the structure with pointers to the caller's strings (no copies).
HRESULT KerbInteractiveUnlockLogonInit(PWSTR pwzDomain, PWSTR pwzUsername, PWSTR pwzPassword,
                                       CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
                                       KERB_INTERACTIVE_UNLOCK_LOGON* pkiul);

// One CoTaskMem buffer: the structure, followed by the string data. The string
// pointers are replaced by offsets from the start of the buffer.
HRESULT KerbInteractiveUnlockLogonPack(const KERB_INTERACTIVE_UNLOCK_LOGON& rkiulIn,
                                       BYTE** prgb, DWORD* pcb);

// Turns the offsets back into pointers, with bounds checks. Used for
// credentials forwarded by RDP.
HRESULT KerbInteractiveUnlockLogonUnpackInPlace(KERB_INTERACTIVE_UNLOCK_LOGON* pkiul, DWORD cb);

HRESULT RetrieveNegotiateAuthPackage(ULONG* pulAuthPackage);

HRESULT FieldDescriptorCoAllocCopy(const CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR& rcpfd,
                                   CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR** ppcpfd);

helpers.cpp:

C++
#include "helpers.h"
#include <shlwapi.h>
#include <strsafe.h>

#include <wincred.h>

#define SECURITY_WIN32
#include <security.h>

#pragma comment(lib, "secur32.lib")
#pragma comment(lib, "advapi32.lib")
#pragma comment(lib, "shlwapi.lib")

static bool InitUnicodeString(UNICODE_STRING* pus, PWSTR pwz)
{
    // A UNICODE_STRING length is a USHORT (bytes). A longer string is refused
    // rather than cut: silently logging on with a truncated password would be
    // a confusing failure, and len * sizeof(WCHAR) must never wrap.
    size_t len = pwz ? wcslen(pwz) : 0;
    const size_t maxChars = (0xFFFF / sizeof(WCHAR)) - 1;   // 32766
    if (len > maxChars)
        return false;

    pus->Length        = static_cast<USHORT>(len * sizeof(WCHAR));
    pus->MaximumLength = static_cast<USHORT>((len + 1) * sizeof(WCHAR));
    pus->Buffer        = pwz;
    return true;
}

// Copies the string to pbDest and stores its offset from pvBase in Buffer.
static void PackUnicodeString(const UNICODE_STRING& src, UNICODE_STRING* pusDst,
                              BYTE* pbDest, const void* pvBase)
{
    pusDst->Length        = src.Length;
    pusDst->MaximumLength = src.Length;
    if (src.Length)
    {
        CopyMemory(pbDest, src.Buffer, src.Length);
        pusDst->Buffer = reinterpret_cast<PWSTR>(
            static_cast<ULONG_PTR>(pbDest - static_cast<const BYTE*>(pvBase)));
    }
    else
    {
        pusDst->Buffer = nullptr;
    }
}

static bool UnpackUnicodeString(void* pvBase, DWORD cb, UNICODE_STRING* pus)
{
    if (pus->Length == 0)
    {
        pus->Buffer = nullptr;
        return true;
    }

    ULONG_PTR offset = reinterpret_cast<ULONG_PTR>(pus->Buffer);
    if (offset % sizeof(WCHAR) != 0 || offset > cb || pus->Length > cb - offset)
        return false;

    pus->Buffer = reinterpret_cast<PWSTR>(static_cast<BYTE*>(pvBase) + offset);
    return true;
}

HRESULT SplitDomainAndUsername(PCWSTR pszQualifiedUserName,
                               PWSTR pszDomain, int cchDomain,
                               PWSTR pszUsername, int cchUsername)
{
    const wchar_t* pchSlash = wcschr(pszQualifiedUserName, L'\\');
    if (!pchSlash)
        return E_INVALIDARG;

    HRESULT hr = StringCchCopyNW(pszDomain, cchDomain, pszQualifiedUserName,
                                 static_cast<size_t>(pchSlash - pszQualifiedUserName));
    if (SUCCEEDED(hr))
        hr = StringCchCopyW(pszUsername, cchUsername, pchSlash + 1);
    return hr;
}

// CredProtectW and CredUnprotectW work on writable buffers and report the
// needed size through a first call that fails with ERROR_INSUFFICIENT_BUFFER.
static HRESULT ProtectCopy(PWSTR pwzWritable, PWSTR* ppwzOut)
{
    *ppwzOut = nullptr;
    DWORD cch = 0;
    const DWORD cchIn = static_cast<DWORD>(wcslen(pwzWritable) + 1);
    if (CredProtectW(FALSE, pwzWritable, cchIn, nullptr, &cch, nullptr) ||
        GetLastError() != ERROR_INSUFFICIENT_BUFFER || cch == 0)
        return E_FAIL;

    PWSTR out = static_cast<PWSTR>(CoTaskMemAlloc(cch * sizeof(WCHAR)));
    if (!out)
        return E_OUTOFMEMORY;
    if (!CredProtectW(FALSE, pwzWritable, cchIn, out, &cch, nullptr))
    {
        HRESULT hr = HRESULT_FROM_WIN32(GetLastError());
        SecureZeroMemory(out, cch * sizeof(WCHAR));
        CoTaskMemFree(out);
        return hr;
    }
    *ppwzOut = out;
    return S_OK;
}

static HRESULT UnprotectCopy(PWSTR pwzWritable, PWSTR* ppwzOut)
{
    *ppwzOut = nullptr;
    DWORD cch = 0;
    const DWORD cchIn = static_cast<DWORD>(wcslen(pwzWritable) + 1);
    if (CredUnprotectW(FALSE, pwzWritable, cchIn, nullptr, &cch) ||
        GetLastError() != ERROR_INSUFFICIENT_BUFFER || cch == 0)
        return E_FAIL;

    PWSTR out = static_cast<PWSTR>(CoTaskMemAlloc(cch * sizeof(WCHAR)));
    if (!out)
        return E_OUTOFMEMORY;
    if (!CredUnprotectW(FALSE, pwzWritable, cchIn, out, &cch))
    {
        HRESULT hr = HRESULT_FROM_WIN32(GetLastError());
        SecureZeroMemory(out, cch * sizeof(WCHAR));
        CoTaskMemFree(out);
        return hr;
    }
    *ppwzOut = out;
    return S_OK;
}

// Shared shape of both directions: work on a private writable copy, decide by
// the protection state, wipe the copy.
static HRESULT CopyPassword(PCWSTR pwzPassword, bool protect,
                            CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus, PWSTR* ppwzOut)
{
    *ppwzOut = nullptr;
    if (!pwzPassword || !*pwzPassword)
        return SHStrDupW(L"", ppwzOut);

    PWSTR copy = nullptr;
    HRESULT hr = SHStrDupW(pwzPassword, &copy);
    if (FAILED(hr))
        return hr;

    CRED_PROTECTION_TYPE type = CredUnprotected;
    if (!CredIsProtectedW(copy, &type))
        hr = HRESULT_FROM_WIN32(GetLastError());
    else if (protect)
        hr = (cpus != CPUS_CREDUI && type == CredUnprotected)
            ? ProtectCopy(copy, ppwzOut)
            : SHStrDupW(copy, ppwzOut);
    else
        hr = (type == CredUnprotected)
            ? SHStrDupW(copy, ppwzOut)
            : UnprotectCopy(copy, ppwzOut);

    SecureZeroMemory(copy, wcslen(copy) * sizeof(WCHAR));
    CoTaskMemFree(copy);
    return hr;
}

HRESULT ProtectIfNecessaryAndCopyPassword(PCWSTR pwzPassword,
                                          CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
                                          PWSTR* ppwzProtectedPassword)
{
    return CopyPassword(pwzPassword, true, cpus, ppwzProtectedPassword);
}

HRESULT CopyUnprotectedPassword(PCWSTR pwzPassword, PWSTR* ppwzPlain)
{
    return CopyPassword(pwzPassword, false, CPUS_LOGON, ppwzPlain);
}

HRESULT KerbInteractiveUnlockLogonInit(PWSTR pwzDomain, PWSTR pwzUsername, PWSTR pwzPassword,
                                       CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
                                       KERB_INTERACTIVE_UNLOCK_LOGON* pkiul)
{
    ZeroMemory(pkiul, sizeof(*pkiul));
    KERB_INTERACTIVE_LOGON* pkil = &pkiul->Logon;

    switch (cpus)
    {
    case CPUS_LOGON:              pkil->MessageType = KerbInteractiveLogon;       break;
    case CPUS_UNLOCK_WORKSTATION: pkil->MessageType = KerbWorkstationUnlockLogon; break;
    default:                      return E_INVALIDARG;
    }

    if (!InitUnicodeString(&pkil->LogonDomainName, pwzDomain) ||
        !InitUnicodeString(&pkil->UserName, pwzUsername) ||
        !InitUnicodeString(&pkil->Password, pwzPassword))
        return E_INVALIDARG;
    return S_OK;
}

HRESULT KerbInteractiveUnlockLogonPack(const KERB_INTERACTIVE_UNLOCK_LOGON& rkiulIn,
                                       BYTE** prgb, DWORD* pcb)
{
    const KERB_INTERACTIVE_LOGON& in = rkiulIn.Logon;
    DWORD cb = sizeof(rkiulIn) + in.LogonDomainName.Length + in.UserName.Length + in.Password.Length;

    KERB_INTERACTIVE_UNLOCK_LOGON* pkiulOut =
        static_cast<KERB_INTERACTIVE_UNLOCK_LOGON*>(CoTaskMemAlloc(cb));
    if (!pkiulOut)
        return E_OUTOFMEMORY;

    ZeroMemory(pkiulOut, sizeof(*pkiulOut));
    pkiulOut->Logon.MessageType = in.MessageType;

    BYTE* pbData = reinterpret_cast<BYTE*>(pkiulOut) + sizeof(*pkiulOut);
    PackUnicodeString(in.LogonDomainName, &pkiulOut->Logon.LogonDomainName, pbData, pkiulOut);
    pbData += in.LogonDomainName.Length;
    PackUnicodeString(in.UserName, &pkiulOut->Logon.UserName, pbData, pkiulOut);
    pbData += in.UserName.Length;
    PackUnicodeString(in.Password, &pkiulOut->Logon.Password, pbData, pkiulOut);

    *prgb = reinterpret_cast<BYTE*>(pkiulOut);
    *pcb  = cb;
    return S_OK;
}

HRESULT KerbInteractiveUnlockLogonUnpackInPlace(KERB_INTERACTIVE_UNLOCK_LOGON* pkiul, DWORD cb)
{
    if (cb < sizeof(*pkiul))
        return E_INVALIDARG;

    KERB_INTERACTIVE_LOGON* pkil = &pkiul->Logon;
    if (UnpackUnicodeString(pkiul, cb, &pkil->LogonDomainName) &&
        UnpackUnicodeString(pkiul, cb, &pkil->UserName) &&
        UnpackUnicodeString(pkiul, cb, &pkil->Password))
        return S_OK;
    return E_INVALIDARG;
}

HRESULT RetrieveNegotiateAuthPackage(ULONG* pulAuthPackage)
{
    HANDLE hLsa = nullptr;
    NTSTATUS status = LsaConnectUntrusted(&hLsa);
    if (status != 0)
        return HRESULT_FROM_NT(status);

    LSA_STRING name;
    name.Buffer        = const_cast<PCHAR>(NEGOSSP_NAME_A);
    name.Length        = static_cast<USHORT>(strlen(name.Buffer));
    name.MaximumLength = static_cast<USHORT>(name.Length + 1);

    ULONG ulPackage = 0;
    status = LsaLookupAuthenticationPackage(hLsa, &name, &ulPackage);
    LsaDeregisterLogonProcess(hLsa);
    if (status != 0)
        return HRESULT_FROM_NT(status);

    *pulAuthPackage = ulPackage;
    return S_OK;
}

HRESULT FieldDescriptorCoAllocCopy(const CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR& rcpfd,
                                   CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR** ppcpfd)
{
    *ppcpfd = nullptr;

    CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR* pcpfd =
        static_cast<CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR*>(
            CoTaskMemAlloc(sizeof(CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR)));
    if (!pcpfd)
        return E_OUTOFMEMORY;

    pcpfd->dwFieldID     = rcpfd.dwFieldID;
    pcpfd->cpft          = rcpfd.cpft;
    pcpfd->guidFieldType = rcpfd.guidFieldType;
    pcpfd->pszLabel      = nullptr;

    HRESULT hr = S_OK;
    if (rcpfd.pszLabel)
        hr = SHStrDupW(rcpfd.pszLabel, &pcpfd->pszLabel);

    if (SUCCEEDED(hr))
        *ppcpfd = pcpfd;
    else
        CoTaskMemFree(pcpfd);
    return hr;
}

ProtectIfNecessaryAndCopyPassword now does the same as the Microsoft sample. It encrypts the copy for the serialization with CredProtect, if it isn't encrypted already. My first version just copied it. That worked, but the password was in plain text in one more buffer in LogonUI. CopyUnprotectedPassword does the opposite, for the LogonUser check. And InitUnicodeString now refuses a string that doesn't fit into a UNICODE_STRING. Before, it cut it. A logon with a silently shortened password would be a really confusing error.

The COM part is standard. One class factory for both CLSIDs. The only thing you can easily get wrong is when the DLL is unloaded. LogonUI regularly asks DllCanUnloadNow if the DLL can be unloaded. As long as any object exists, the answer has to be no. That's why the provider, the tile and the filter call DllAddRef() in the constructor and DllRelease() in the destructor. If you forget this, the DLL can be unloaded while the tile is still in use and LogonUI crashes. And only sometimes, which makes it really hard to find.

guid.h:

C++
#pragma once
#include <guiddef.h>

// Generate your own GUIDs before using this anywhere and keep them in sync
// with the .reg files. dll.cpp includes initguid.h before this header, so the
// GUIDs are defined there and only declared everywhere else.

// {B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE01}
DEFINE_GUID(CLSID_CTacProvider,
    0xb1e7c9a0, 0x2f4d, 0x4c6b, 0x9a, 0x11, 0x00, 0x00, 0xc0, 0xff, 0xee, 0x01);

// {B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE02}
DEFINE_GUID(CLSID_CTacFilter,
    0xb1e7c9a0, 0x2f4d, 0x4c6b, 0x9a, 0x11, 0x00, 0x00, 0xc0, 0xff, 0xee, 0x02);

dll.h:

C++
#pragma once
#include <windows.h>

void DllAddRef();
void DllRelease();

HRESULT CTacProvider_CreateInstance(REFIID riid, void** ppv);
HRESULT CTacFilter_CreateInstance(REFIID riid, void** ppv);

dll.cpp:

C++
#include <windows.h>
#include <new>

#include <initguid.h>
#include "guid.h"
#include "dll.h"

// Number of live objects. LogonUI calls DllCanUnloadNow from time to time and
// must not unload the DLL while a provider, credential or filter still exists.
static LONG g_cRef = 0;

void DllAddRef()  { InterlockedIncrement(&g_cRef); }
void DllRelease() { InterlockedDecrement(&g_cRef); }

class CClassFactory : public IClassFactory
{
public:
    explicit CClassFactory(REFCLSID rclsid) : _cRef(1), _clsid(rclsid) { DllAddRef(); }

    IFACEMETHODIMP_(ULONG) AddRef() { return InterlockedIncrement(&_cRef); }

    IFACEMETHODIMP_(ULONG) Release()
    {
        LONG cRef = InterlockedDecrement(&_cRef);
        if (cRef == 0)
            delete this;
        return cRef;
    }

    IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv)
    {
        if (!ppv)
            return E_POINTER;
        if (IsEqualIID(riid, IID_IUnknown) || IsEqualIID(riid, IID_IClassFactory))
        {
            *ppv = static_cast<IClassFactory*>(this);
            AddRef();
            return S_OK;
        }
        *ppv = nullptr;
        return E_NOINTERFACE;
    }

    IFACEMETHODIMP CreateInstance(IUnknown* pUnkOuter, REFIID riid, void** ppv)
    {
        if (pUnkOuter)
            return CLASS_E_NOAGGREGATION;
        if (IsEqualCLSID(_clsid, CLSID_CTacProvider))
            return CTacProvider_CreateInstance(riid, ppv);
        if (IsEqualCLSID(_clsid, CLSID_CTacFilter))
            return CTacFilter_CreateInstance(riid, ppv);
        return CLASS_E_CLASSNOTAVAILABLE;
    }

    IFACEMETHODIMP LockServer(BOOL fLock)
    {
        if (fLock)
            DllAddRef();
        else
            DllRelease();
        return S_OK;
    }

private:
    ~CClassFactory() { DllRelease(); }

    LONG  _cRef;
    CLSID _clsid;
};

STDAPI DllGetClassObject(REFCLSID rclsid, REFIID riid, void** ppv)
{
    if (!ppv)
        return E_POINTER;
    *ppv = nullptr;
    if (!IsEqualCLSID(rclsid, CLSID_CTacProvider) && !IsEqualCLSID(rclsid, CLSID_CTacFilter))
        return CLASS_E_CLASSNOTAVAILABLE;

    CClassFactory* pcf = new (std::nothrow) CClassFactory(rclsid);
    if (!pcf)
        return E_OUTOFMEMORY;

    HRESULT hr = pcf->QueryInterface(riid, ppv);
    pcf->Release();
    return hr;
}

STDAPI DllCanUnloadNow()
{
    return InterlockedCompareExchange(&g_cRef, 0, 0) == 0 ? S_OK : S_FALSE;
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, LPVOID)
{
    if (dwReason == DLL_PROCESS_ATTACH)
        DisableThreadLibraryCalls(hModule);
    return TRUE;
}

TacProvider.def:

LIBRARY   TacProvider
EXPORTS
    DllGetClassObject   PRIVATE
    DllCanUnloadNow     PRIVATE

A word about the GUIDs. They are the CLSIDs of the provider and the filter. Windows uses them to find the COM class in the registry: LogonUI reads ...\Credential Providers\{GUID} and loads the DLL from HKCR\CLSID\{GUID}. The GUID is not a secret, it is just a name.

A problem only happens if two different DLLs register the same CLSID on the same machine. Then the second registration overwrites the first one and Windows only loads one of them. For example, if you copy this code, change it and install it on a machine where another version with the same GUIDs is already running.

For a test in your own VM you can keep the GUIDs from this article. If you want to use the code for something real, or run two versions side by side, create your own. You don't have to do this by hand. new-guids.ps1 creates two new GUIDs and writes them into guid.h and all .reg files:

PowerShell
# Creates new CLSIDs for the provider and the filter and writes them into
# guid.h and all .reg files in this folder.
#
# Run it once in the project folder, then rebuild with build.bat.
# If an older version is still registered, run unregister.reg BEFORE this
# script. A copy with the old GUIDs is kept as unregister-old.reg anyway.

$ErrorActionPreference = 'Stop'
$root     = $PSScriptRoot
$guidFile = Join-Path $root 'guid.h'
$regFiles = Get-ChildItem -Path $root -Filter '*.reg' | Where-Object { $_.Name -ne 'unregister-old.reg' }

# The first two {...} in guid.h are the current provider and filter GUIDs.
$found = [regex]::Matches((Get-Content $guidFile -Raw), '\{([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})\}')
if ($found.Count -lt 2) { throw 'Could not find the current GUIDs in guid.h.' }
$oldProvider = $found[0].Groups[1].Value.ToUpper()
$oldFilter   = $found[1].Groups[1].Value.ToUpper()

$newProvider = [guid]::NewGuid().ToString().ToUpper()
$newFilter   = [guid]::NewGuid().ToString().ToUpper()

# {B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE01} becomes
# 0xb1e7c9a0, 0x2f4d, 0x4c6b, 0x9a, 0x11, 0x00, 0x00, 0xc0, 0xff, 0xee, 0x01
function Get-DefineGuid([string]$name, [string]$guid)
{
    $hex   = $guid.Replace('-', '').ToLower()
    $bytes = @()
    for ($i = 16; $i -lt 32; $i += 2) { $bytes += '0x' + $hex.Substring($i, 2) }
    $parts = @('0x' + $hex.Substring(0, 8), '0x' + $hex.Substring(8, 4), '0x' + $hex.Substring(12, 4)) + $bytes
    return "DEFINE_GUID($name,`r`n    " + ($parts -join ', ') + ');'
}

# Keep an unregister file with the old GUIDs, in case they are still registered.
$unregister = Join-Path $root 'unregister.reg'
if (Test-Path $unregister) { Copy-Item $unregister (Join-Path $root 'unregister-old.reg') -Force }

$lines = @(
    '#pragma once'
    '#include <guiddef.h>'
    ''
    '// Created by new-guids.ps1. Keep them in sync with the .reg files.'
    '// dll.cpp includes initguid.h before this header, so the GUIDs are defined'
    '// there and only declared everywhere else.'
    ''
    "// {$newProvider}"
    (Get-DefineGuid 'CLSID_CTacProvider' $newProvider)
    ''
    "// {$newFilter}"
    (Get-DefineGuid 'CLSID_CTacFilter' $newFilter)
)
Set-Content -Path $guidFile -Value ($lines -join "`r`n") -Encoding Ascii

foreach ($file in $regFiles)
{
    $text = Get-Content $file.FullName -Raw
    $text = $text -ireplace [regex]::Escape($oldProvider), $newProvider
    $text = $text -ireplace [regex]::Escape($oldFilter),   $newFilter
    Set-Content -Path $file.FullName -Value $text -Encoding Ascii -NoNewline
}

Write-Host ''
Write-Host "Provider: {$oldProvider} -> {$newProvider}"
Write-Host "Filter:   {$oldFilter} -> {$newFilter}"
Write-Host ''
Write-Host 'Updated guid.h and:' ($regFiles.Name -join ', ')
Write-Host 'Now rebuild with build.bat and register again.'

Run it in the project folder and rebuild:

PowerShell
powershell -ExecutionPolicy Bypass -File .\new-guids.ps1
.\build.bat

If an older version is already registered, run unregister.reg first, while it still contains the old GUIDs. The script also keeps a copy with the old GUIDs as unregister-old.reg, just in case.

Build

You don't have to compile every file by hand.

Option 1: build.bat

Open the "x64 Native Tools Command Prompt" of your Visual Studio version from the start menu, go to the project folder and run build.bat.

It has to be the x64 prompt. LogonUI on a 64-bit Windows does not load a 32-bit DLL.

The script builds with /MT. This links the C++ runtime into the DLL. Without it the VM would need the Visual C++ Redistributable, and if it is missing, LogonUI doesn't load the DLL. You don't get an error, the tile just doesn't show up.

It also builds with /O2 /guard:cf /W4 /sdl and links with /CETCOMPAT. That means optimized, with Control Flow Guard, warning level 4, the extra SDL checks, and marked compatible with CET shadow stacks. Code that LogonUI loads as SYSTEM should have that. The build.bat in the repo also builds the two LSA DLLs from Part 2. The version here only has the part of this article. And the repo has a GitHub Actions workflow. On every push it builds everything with MSVC, checks that Control Flow Guard is really on, and runs the code analysis and the tests.

build.bat:

Command Prompt
@echo off
REM =====================================================================
REM  Build TacProvider.dll and enroll.exe in one shot.
REM
REM  Run this from the "x64 Native Tools Command Prompt" of your Visual
REM  Studio / Build Tools version (Start menu -> Visual Studio folder). That shell puts
REM  cl.exe and the Windows SDK on your PATH automatically.
REM
REM  Usage:   build.bat
REM  Output:  TacProvider.dll  and  enroll.exe  in this folder.
REM  /MT links the C++ runtime statically, so the target machine needs
REM  no Visual C++ Redistributable. /O2 optimizes, /guard:cf turns on Control
REM  Flow Guard, /CETCOMPAT marks the binaries shadow-stack compatible, /sdl
REM  adds extra runtime checks and turns a few dangerous
REM  warnings into errors - worth having in anything LogonUI or lsass loads.
REM =====================================================================

setlocal
cd /d "%~dp0"

echo.
echo === Building TacProvider.dll (credential provider + filter) ===
cl /nologo /LD /MT /O2 /guard:cf /EHsc /std:c++17 /W4 /sdl /DUNICODE /D_UNICODE ^
   dll.cpp CTacProvider.cpp CTacCredential.cpp CTacFilter.cpp ^
   helpers.cpp totp.cpp store.cpp store_index.cpp verify.cpp statelock.cpp eventlog.cpp ^
   /Fe:TacProvider.dll ^
   /link /guard:cf /CETCOMPAT /DEF:TacProvider.def ^
   bcrypt.lib crypt32.lib advapi32.lib secur32.lib shlwapi.lib ole32.lib uuid.lib gdi32.lib user32.lib wtsapi32.lib
if errorlevel 1 goto :fail

echo.
echo === Building enroll.exe (enrollment tool) ===
cl /nologo /MT /O2 /guard:cf /EHsc /std:c++17 /W4 /sdl /DUNICODE /D_UNICODE ^
   enroll.cpp totp.cpp store.cpp store_index.cpp statelock.cpp ^
   /Fe:enroll.exe ^
   /link /guard:cf /CETCOMPAT bcrypt.lib crypt32.lib advapi32.lib
if errorlevel 1 goto :fail

echo.
echo === Done. Cleaning up intermediate files ===
del /q *.obj *.exp 2>nul

echo.
echo Built: TacProvider.dll  and  enroll.exe
echo (TacProvider.lib is just the import library; you don't deploy it.)
goto :eof

:fail
echo.
echo BUILD FAILED - see the compiler errors above.
exit /b 1

If everything works, it looks like this. Every source file shows up once, and at the end you have TacProvider.dll and enroll.exe:

Option 2: CMake

If you have CMake installed:

cmake -B build -A x64
cmake --build build --config Release

CMakeLists.txt:

CMake
cmake_minimum_required(VERSION 3.20)
project(TacProvider CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_compile_definitions(UNICODE _UNICODE)

# Static C++ runtime (/MT): the target machine needs no VC++ Redistributable.
# Without this, LogonUI silently fails to load the DLL on a clean VM.
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")

# These binaries run in LogonUI: Control Flow Guard and SDL checks, warning level 4.
if(MSVC)
    add_compile_options(/guard:cf /sdl /W4)
    add_link_options(/guard:cf /CETCOMPAT)
endif()

# --- the credential provider + filter DLL ---
add_library(TacProvider SHARED
    dll.cpp
    CTacProvider.cpp
    CTacCredential.cpp
    CTacFilter.cpp
    helpers.cpp
    totp.cpp
    store.cpp
    store_index.cpp
    verify.cpp
    statelock.cpp
    eventlog.cpp)

target_link_libraries(TacProvider PRIVATE
    bcrypt crypt32 advapi32 secur32 shlwapi ole32 uuid gdi32 user32 wtsapi32)

# apply the module-definition file (the COM exports)
set_target_properties(TacProvider PROPERTIES
    LINK_FLAGS "/DEF:\"${CMAKE_CURRENT_SOURCE_DIR}/TacProvider.def\"")

# --- the enrollment console tool ---
add_executable(enroll
    enroll.cpp
    totp.cpp
    store.cpp
    store_index.cpp
    statelock.cpp)

target_link_libraries(enroll PRIVATE
    bcrypt crypt32 advapi32)

# ---------------------------------------------------------------------
# Build (from a normal PowerShell/cmd, CMake must be installed):
#   cmake -B build -A x64
#   cmake --build build --config Release
# Outputs land in build\Release\TacProvider.dll and build\Release\enroll.exe
# ---------------------------------------------------------------------

Both options create TacProvider.dll and enroll.exe. The TacProvider.lib is only the import library and is not needed.

Install

The registration is split into three files. register.reg registers the provider and the event log source, but hides nothing:

INI
Windows Registry Editor Version 5.00

; Step 1: COM registration + provider. Does NOT hide any other tile.
; GUIDs here MUST match guid.h. Regenerate both before using this anywhere.
;   provider {B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE01}
;   filter   {B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE02}
; The DLL lives under Program Files so only admins can replace it.

[HKEY_CLASSES_ROOT\CLSID\{B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE01}]
@="TheAdminCafe 2FA Credential Provider"
[HKEY_CLASSES_ROOT\CLSID\{B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE01}\InprocServer32]
@="C:\\Program Files\\TheAdminCafe\\TacProvider.dll"
"ThreadingModel"="Apartment"

[HKEY_CLASSES_ROOT\CLSID\{B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE02}]
@="TheAdminCafe 2FA Credential Provider Filter"
[HKEY_CLASSES_ROOT\CLSID\{B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE02}\InprocServer32]
@="C:\\Program Files\\TheAdminCafe\\TacProvider.dll"
"ThreadingModel"="Apartment"

[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\{B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE01}]
@="TheAdminCafe 2FA Credential Provider"

; Event log source for the 2FA events (Application log). EventCreate.exe has a
; message table that prints the text as it is (%1) for the IDs 1 to 1000.
[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\EventLog\Application\TheAdminCafe 2FA]
"EventMessageFile"=hex(2):25,00,53,00,79,00,73,00,74,00,65,00,6d,00,52,00,6f,00,6f,00,74,00,25,00,5c,00,53,00,79,00,73,00,74,00,65,00,6d,00,33,00,32,00,5c,00,45,00,76,00,65,00,6e,00,74,00,43,00,72,00,65,00,61,00,74,00,65,00,2e,00,65,00,78,00,65,00,00,00
"TypesSupported"=dword:00000007

register-filter.reg adds the filter that hides all other tiles:

INI
Windows Registry Editor Version 5.00

; Step 2: the filter. Hides every other tile (password, PIN, Hello).
; Import this ONLY after an enrolled account can log on through the 2FA tile.

[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Provider Filters\{B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE02}]
@="TheAdminCafe 2FA Credential Provider Filter"

unregister.reg removes the registration again:

INI
Windows Registry Editor Version 5.00

; Removes the filter, the provider and the COM registration.
; The enrolled secrets and the lock state under HKLM\SOFTWARE\TheAdminCafe
; are NOT removed by this file. Run "enroll.exe /purge" BEFORE importing it.

[-HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Provider Filters\{B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE02}]
[-HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\{B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE01}]
[-HKEY_CLASSES_ROOT\CLSID\{B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE01}]
[-HKEY_CLASSES_ROOT\CLSID\{B1E7C9A0-2F4D-4C6B-9A11-0000C0FFEE02}]
[-HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\EventLog\Application\TheAdminCafe 2FA]

It doesn't touch the secrets and the state in the registry. For a full uninstall run enroll.exe /purge first, then unregister.reg, reboot and delete the folder. Without /purge the secrets stay and come back on a reinstall.

Please follow this order.

1. Snapshot

Create a snapshot of the VM. Really.

2. Folder under Program Files

Create the folder C:\Program Files\TheAdminCafe\. Please don't use a folder directly under C:\. Such folders inherit rights that allow every user to change files in them. A normal user could then replace the DLL and LogonUI would run his code as SYSTEM.

Copy TacProvider.dll and enroll.exe into it. I also put the three .reg files there, so I have everything in one place:

3. Register the provider

Start PowerShell as administrator, go to the folder and import register.reg:

PowerShell
cd "C:\Program Files\TheAdminCafe"
reg import .\register.reg

This registers the provider and the event log source. It doesn't hide anything yet.

4. Check the logon screen

Log off. At first the logon screen looks the same as before. Click "Sign-in options":

Next to the key (the normal password provider) there is now our brown "2FA" icon. Click it, and you get three fields: username, password and one-time code.

On my VM Windows shows "Other user" and no list of accounts. That's why you see the "Other user" tile with a username field here. On a machine that lists the local users, you click a user first and only get password and code.

If something is wrong, you can still use the key icon and log on with the password.

5. Enroll and test

For the test I create a new local user test, put it into the Administrators group and enroll it:

PowerShell
net user test "<password>" /add
net localgroup "Administrators" "test" /add
& "C:\Program Files\TheAdminCafe\enroll.exe" test

You see the SID of the account, the secret and the otpauth:// URI, and enroll.exe waits for a code. This is a throwaway VM that gets deleted, so it doesn't matter that password and secret are in the screenshot. On a real machine never show them to anybody.

Now add the secret to your authenticator app. I use the Google Authenticator: "+" → "Enter a setup key", give the account a name, paste the secret and leave the type on "Time based". That's exactly what enroll.exe created: TOTP with SHA1, 6 digits and 30 seconds, the defaults of every authenticator app.

The app shows a new code right away. The small circle next to it is the 30 second countdown until the next code:

Type this code into enroll.exe, which is still waiting. Only now the secret is stored. If the code is wrong, check the secret in the app and the clock on both devices. Nothing was stored, so you can just run enroll.exe again.

Then wait for the next code, log off, choose the 2FA tile and enter username, password and the current code:

First try a wrong code on purpose. The tile refuses it and no logon is started:

With the correct code the logon goes through:

Every attempt should now show up in the Application log with the source TheAdminCafe 2FA. Enroll every other account you need the same way, including a break-glass admin.

6. Enable the filter

Only if everything works, import register-filter.reg and log off again:

PowerShell
reg import .\register-filter.reg

Now the sign-in options are gone. The key icon disappears, and every logon asks for password and code. If you import the filter before any account is enrolled, it hides nothing yet.

The filter is the part that can lock you out. That's why it comes last.

The repo also has install-doppio.ps1. It does steps 2 and 3 for you and first checks that no other provider holds our CLSIDs. With -EnableFilter it does this step, but only if every enabled local account is enrolled. If one isn't, it stops and names it, because with the filter on that account couldn't log on at the console or over RDP anymore. An account that never logs on interactively, a service account for example, you name with -AllowUnenrolled. Its first version imported the filter right away and told you to keep a second admin not enrolled. With the filter on, that admin was locked out.

Test

I tested the following:

#TestExpected result
1Enrolled user, correct password, current codeLogon works
2Enrolled user, correct password, wrong or old code"Invalid one-time code.", no logon
3User not enrolled, correct passwordSame message as in test 2, no logon
4Enrolled user, wrong password"The user name or password is incorrect.", code not checked, no counter, event 107
5Filter off, click a user, open "Sign-in options"Brown "2FA" icon next to password and PIN
6Filter activeOnly our user tiles, no sign-in options, no password/PIN/Hello
7Lock screen and unlock with password and codeUnlock works
8Normal user reads HKLM\SOFTWARE\TheAdminCafe\2FAAccess denied
9enroll.exe with a name that doesn't exist"is not a local user account", nothing is stored
10Microsoft account on the machineGets no tile from our provider
11RDP with NLAUsername and password are filled in, code is still required
12Log on, log off and log on again with the same code"Invalid one-time code.", only the next code works
13Correct password, 5 wrong codes in a row"Too many wrong codes. Try again in 5 minutes."
14Correct code while the account is lockedStill locked, the code is not checked
15enroll.exe /unlock aliceLogon works again with the next code
16Rename a user, create a new user with the old nameThe renamed user still logs in, the new one has no secret
17Wrong code, then check the Application logEvent 101 with account, SID and console/RDP address
18Restart into Safe Mode (Shift + Restart)Check what you get. See "Attacks that never touch the tile"
19Wrong password, 10 timesWindows lockout policy locks the account, our counter stays at 0
20enroll.exe with a wrong confirmation code"Not enrolled", nothing is stored
21Same code in two RDP sessions at the same timeOnly one logon works
22Filter on, no account enrolledAll sign-in options are visible
23Filter on, rename TacProvider.dll and rebootAll sign-in options are visible again
24Filter on, account that Windows doesn't listLogon works through "Other user"

For test 8 run this as a normal user:

PowerShell
Get-ItemProperty HKLM:\SOFTWARE\TheAdminCafe\2FA

Debugging and what to do if you are locked out

LogonUI runs as SYSTEM on the Secure Desktop. You can't just attach Visual Studio. If the Credential Provider crashes, you don't get an error message, the logon just doesn't work anymore.

What helped me:

  • Snapshot before every install. If something goes wrong, go back.
  • Write logs to a file (in a folder where SYSTEM can write) or use ETW. Real debugging on the Secure Desktop needs remote or kernel debugging and that is a lot of work for one log line.
  • Only enable the filter at the very end.

If you are locked out, restore the snapshot. If you don't have one but know the password of a local admin, Safe Mode is the quickest way back: Windows doesn't load our provider and our filter there (more about this in the next chapter). Log on with the normal password tile, delete the filter key and reboot. Without a working password, boot into the Windows Recovery Environment, load the registry offline and delete the filter key under HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Provider Filters\. Renaming TacProvider.dll from the WinRE command prompt works too, because then the filter can't be loaded either.

If the problem is only the lock (too many wrong codes), you don't need any of this. Another admin runs enroll.exe /unlock <user>, or you wait at most 60 minutes.

And now look at the recovery tips again: Safe Mode, or WinRE and an offline registry. Both work exactly the same for an attacker. That's the next chapter.

Attacks that never touch the tile

Everything so far protects the tile. But the tile is only a DLL that Windows loads. Whoever sits in front of the machine and controls how Windows starts doesn't have to talk to the tile at all.

This is the picture for the whole chapter. Only one boot path ends at our tile, and each mitigation below closes one of the others:

Safe Mode

In Safe Mode Windows doesn't load third-party Credential Providers. Not our provider and not our filter. What's left is the normal password tile. Duo writes the same about their product: two-factor "may be bypassed by restarting Windows into Safe Mode".

And getting there is easy. You don't have to be logged in:

  1. On the logon screen hold Shift and click Restart in the power menu.
  2. Troubleshoot → Advanced options → Startup Settings → Restart.
  3. Press 4 for Safe Mode.

After that the password alone is enough. So everyone who knows the password and sits in front of the machine can skip the code. Please test this in your VM (test 18). It's better to see it yourself than to trust me.

A Credential Provider can't fix this, because in Safe Mode it doesn't run. What helps:

  • BitLocker with TPM + PIN. Every restart asks for the PIN before Windows starts. Someone who only knows the Windows password can't restart into Safe Mode, because they don't get past the PIN. BitLocker with TPM only doesn't help here. The TPM unlocks the disk automatically, also for Safe Mode.
  • Disable the Recovery Environment with reagentc /disable. The Startup Settings menu is part of WinRE, so Shift + Restart has nothing to offer anymore. The price: no automatic repair if Windows doesn't boot. Test it and keep a way back (installation media and your BitLocker recovery key).
  • A UEFI password and a fixed boot order, so nobody boots from a USB stick.

MITRE ATT&CK lists Safe Mode as its own technique (T1688, formerly T1562.009). Ransomware uses it to start without the EDR. Our provider is in the same spot as that EDR: it only runs in normal mode.

Offline

With physical access and an unencrypted disk there are a lot of ways. Boot into WinRE or from a USB stick and delete the filter key, like in my recovery tip. Or replace utilman.exe with cmd.exe and get a SYSTEM shell on the logon screen. Or just copy the disk. None of this ever sees our tile.

Against all of this there is only one real answer: BitLocker. With an encrypted disk, WinRE and a USB stick only see encrypted data. The command prompt in WinRE asks for the recovery key first. Without BitLocker this 2FA only protects against people who can't reboot the machine.

The clock

TOTP trusts the clock of the machine. In my first version this was a real attack: write down a code, turn the clock back in the BIOS, disconnect the network, boot and type the old code. The replay protection with <= closes this, because the old step is smaller than the last used one. What is left: someone who turns the clock forward skips the lock. That's physical access again, and a UEFI password stops it.

What this means

For a notebook that can be stolen, 2FA without BitLocker doesn't help much. With BitLocker (TPM + PIN), a UEFI password and WinRE under control, the attacks above don't work anymore. Then the 2FA protects what it is built for: the logon screen, at the console and over RDP.

Limits

This demo is a real 2FA, but only for one path. What it can't do:

  • Only the interactive logon and unlock are protected. Network logon, runas, scheduled tasks, remoting, WMI and UAC are not.
  • Safe Mode and offline access go around the provider completely. Without BitLocker (TPM + PIN) and a UEFI password the 2FA only protects against people who can't reboot the machine.
  • An admin can read the secret. The ACL only protects against normal users. And because of the machine scope of DPAPI, every copy of the SOFTWARE hive (backup, shadow copy) is as good as the secret on this machine. The better solution would be a key in the TPM.
  • Someone who knows the password can lock the account with 5 wrong codes. Without the password, wrong codes are not counted.
  • The tile tells you if a password is correct before it asks for the code. The normal password tile does the same. The Windows lockout policy limits the guessing, so set one.
  • Every sign-in creates one extra logon event 4624 in the Security log, from the password check.
  • The lock file only works if the install folder is writable for administrators only. Install under C:\Program Files, as described.
  • The lock depends on the clock. Someone who sets the clock forward skips it. That needs physical access again.
  • The code comparison is not constant-time. With 6 digits this is not a real problem, but it would be cleaner.
  • If a user loses their phone, an admin has to enroll them again.
  • Only local accounts are supported. Domain and Microsoft accounts get no tile from our provider, so with the filter active they cannot log on interactively anymore.
  • On machines where Windows doesn't list local users (often domain joined ones), you only get the "Other user" tile with a username field.

The other side

The same technique that I use for 2FA is also used by attackers. A malicious Credential Provider runs as SYSTEM on the logon screen and sees every password in plain text. MITRE ATT&CK lists this under T1556 (Modify Authentication Process).

That's why you should check which Credential Providers and filters are registered on your machines:

PowerShell
Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers'
Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Provider Filters'

Save the list once and alert if something changes. And sign your own DLL. An unsigned DLL in LogonUI is exactly what your EDR should complain about.

What real products do differently

Most of the limits above have the same cause. The Credential Provider only sits in the UI, so it only sees the UI. If you want 2FA for more than the logon screen, the check has to be in LSA: a custom authentication package, an MSV1_0 sub-authentication filter or an SSP/AP. AuthLite works in LSA.

Real products also don't do the check inside LogonUI. A central service keeps the lock counter in one place, can keep the secret in the TPM and can ask a server, so there is one lock for all machines and not one per machine. The demo gets by with a lock file, but only per machine.

The LSA part is a lot more difficult. The code runs in lsass.exe, which is a Protected Process Light on a hardened system and has to work together with Credential Guard. If you have a bug there, you don't get an exception. LSA crashes and takes the whole machine with it.

I tried that in Part 2, and Part 3 gives enrolled accounts the network back with a gate that you open with a code.

For now we have a working 2FA for local accounts that you can build and test yourself. And you know exactly what it protects and what not.

Licensed under CC BY-NC-SA 4.0.

Still sipping? Another one?

pairs well with this one

the admin café

Open whenever the shell is. Right now, apparently.

Tip jar. Costs nothing, cheers me up anyway.

© 2023–2026 Lukas. Servus.

Esc

Fresh today

Type to search all articles. ↑ ↓ and Enter to open a result.