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
theadmincafe
WindowsSep 30, 202644 min read

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.

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 actually 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.

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, it is important to understand 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 the password is never sent to Windows.

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".

Why this is the case is explained in the next chapter.

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 logon chain

This is the most important part of the whole article. When you log in, the following happens:

  Winlogon.exe         manages the session and the Secure Desktop,
      |                handles Ctrl+Alt+Del
      v
  LogonUI.exe          shows the logon screen and loads the Credential Providers
      |
      v
  Credential           shows the tile, collects the input and
  Provider (DLL)       packs it into a blob (serialization)
      |
      v
  LSA (lsass.exe)      receives the blob and passes it to an authentication package
      |
      v
  Authentication       MSV1_0 (NTLM / local SAM), Kerberos or a custom package.
  Package              This is where the password is actually checked.

The Credential Provider does not check the password. 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. The provider never finds out if the password was correct. It just hands over the blob.

This is important for everything that follows. It has two consequences:

  1. The provider can refuse to create the blob. No blob, no logon. This is our 2FA: if the code is wrong, we simply don't create the blob.
  2. The provider only sees logons that go through its tile. Network logons, runas, services and UAC prompts go to LSA on another way. Our code is never called for them.

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 not just one object. It 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 simply 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. This is important. 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:

  enroll.exe (admin, once per user)
      |  creates a 160-bit secret, encodes it in Base32,
      |  prints an otpauth:// URI for the phone,
      |  encrypts the secret with DPAPI and saves it
      v
  HKLM\SOFTWARE\TheAdminCafe\2FA\<SID>         (REG_BINARY, DPAPI blob,
                                                only SYSTEM + Admins have access)
  HKLM\SOFTWARE\TheAdminCafe\2FA\State\<SID>   (REG_BINARY: last used time step,
                                                wrong codes in a row, locked until)
      ^
      |  read, decrypt and update at logon (LogonUI runs as SYSTEM)
      |
  TacProvider.dll
      |- CTacProvider    one tile per local user, only logon + unlock
      |- CTacCredential  username + password + code, checks the code
      |                  and only then sends the password to LSA
      |- VerifyOtp       replay protection, lock after wrong codes
      |- LogEvent        every attempt goes to the Application log
      |- CTacFilter      hides all other tiles, forwards RDP credentials
      v
  LSA -> Negotiate -> MSV1_0 (local SAM)

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
verify.h/.cppReplay protection and lock after wrong codes
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

Let's start with 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). 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:

cpp
#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:

cpp
#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;
    }
}

Some things I want to point out:

  • 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.

It is important that the check is <= 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. For TOTP the clock of the machine is the truth. 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

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")

# --- 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)

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, the same code a second time doesn't, the step before doesn't, the step after does, and an old code after turning the clock back doesn't. At the end it has to say ALL CHECKS PASS: True.

Secret store

store.h:

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

namespace tac
{
    // "PC\Alice", ".\alice" and "alice" all become "alice".
    std::wstring NormalizeUser(const std::wstring& user);

    // 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 secret is stored per SID, not per name. A renamed or re-created
    // account is a different account.
    bool StoreSecret(const std::wstring& sid, const std::string& base32Secret);
    bool LoadSecretKey(const std::wstring& sid, std::vector<BYTE>& key);

    // 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.
    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:

cpp
#include "store.h"
#include "totp.h"
#include <wincrypt.h>
#include <sddl.h>
#include <cwctype>

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

namespace tac
{
    static const wchar_t kKeyPath[]   = L"SOFTWARE\\TheAdminCafe\\2FA";
    static const wchar_t kStatePath[] = L"SOFTWARE\\TheAdminCafe\\2FA\\State";

    // 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)
    {
        std::wstring name = user;
        size_t pos = name.find_last_of(L'\\');
        if (pos != std::wstring::npos)
            name = name.substr(pos + 1);
        for (wchar_t& c : name)
            c = static_cast<wchar_t>(towlower(c));
        return name;
    }

    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.
        // No UI may ever appear on the secure desktop.
        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 = {};

        // The scope is stored in the blob, so no LOCAL_MACHINE flag here.
        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.
    static bool IsSidString(const std::wstring& sid)
    {
        return sid.size() > 4 && sid.compare(0, 4, L"S-1-") == 0;
    }

    // Creates or opens a key below HKLM with our ACL. The security attributes
    // only apply when the key is created. An existing key might still carry a
    // weaker ACL, so it is set again.
    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 };
        DWORD disposition = 0;
        LSTATUS status = RegCreateKeyExW(HKEY_LOCAL_MACHINE, path, 0, nullptr, 0,
                                         KEY_SET_VALUE | KEY_QUERY_VALUE | WRITE_DAC,
                                         &sa, phKey, &disposition);
        if (status == ERROR_SUCCESS && disposition == REG_OPENED_EXISTING_KEY)
        {
            status = RegSetKeySecurity(*phKey, DACL_SECURITY_INFORMATION, psd);
            if (status != ERROR_SUCCESS)
            {
                RegCloseKey(*phKey);
                *phKey = nullptr;
            }
        }

        LocalFree(psd);
        return status;
    }

    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;

        HKEY hKey = nullptr;
        LSTATUS status = OpenProtectedKey(kKeyPath, &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, kKeyPath, 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 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, kStatePath, 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(kStatePath, &hKey);
        if (status == ERROR_SUCCESS)
        {
            status = RegSetValueExW(hKey, sid.c_str(), 0, REG_BINARY,
                                    reinterpret_cast<const BYTE*>(&state), sizeof(state));
            RegFlushKey(hKey);   // survive a hard reset right after the logon
            RegCloseKey(hKey);
        }
        return status == ERROR_SUCCESS;
    }

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

        HKEY hKey = nullptr;
        LSTATUS status = RegOpenKeyExW(HKEY_LOCAL_MACHINE, kStatePath, 0, KEY_SET_VALUE, &hKey);
        if (status == ERROR_FILE_NOT_FOUND)
            return true;
        if (status != ERROR_SUCCESS)
            return false;

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

The protection of the secret is a bit tricky, so I want to explain it.

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.

Think this through for a second. 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.

The real protection 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 you would store the secret in the TPM.

Some more details:

  • RegCreateKeyEx only applies the security attributes when it creates the key. If the key already exists, maybe with a weaker ACL, I set the ACL again explicitly with RegSetKeySecurity.
  • 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 and for the state. So both keys always get the same ACL, also when they already existed.
  • 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 calls RegFlushKey. The registry is written to disk lazily. If someone pulls the power right after a logon, the used time step must not get lost. Otherwise the same code would work again after the reboot.

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 are simply not found 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:

cpp
#include "totp.h"
#include "store.h"
#include <bcrypt.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::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;
}

int wmain(int argc, wchar_t** argv)
{
    bool unlock = argc == 3 && _wcsicmp(argv[1], L"/unlock") == 0;
    if (argc != 2 && !unlock)
    {
        wprintf(L"Usage: enroll <local username>\n");
        wprintf(L"       enroll /unlock <local username>\n");
        return 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(argv[unlock ? 2 : 1]);
    std::wstring sid;
    if (!tac::ResolveLocalUserSid(user, sid))
    {
        wprintf(L"'%s' is not a local user account on this machine.\n", user.c_str());
        return 1;
    }

    if (unlock)
        return Unlock(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));

    // A new secret starts with a clean state: no lock, no used time step.
    if (!tac::StoreSecret(sid, secret) || !tac::DeleteState(sid))
    {
        wprintf(L"Could not store the secret. Run this from an elevated prompt.\n");
        SecureZeroMemory(&secret[0], secret.size());
        return 1;
    }

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

    wprintf(L"\nEnrolled '%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, then test the logon.\n");
    wprintf(L"Don't paste it into an online QR code generator. Clear this window\n");
    wprintf(L"afterwards (cls), the secret is still in the scrollback.\n");

    SecureZeroMemory(&secret[0], secret.size());
    SecureZeroMemory(&uri[0], uri.size());
    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. The secret is stored under the SID of the account, and the old state of the user is deleted. A new secret starts clean: no lock, no used time step.

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.

The Credential Provider

Fields of the tile

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

cpp
#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 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:

cpp
#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:

cpp
#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: the secret
// is stored per bare name 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 (FAILED(hr))
    {
        cred->Release();
        return hr;
    }

    _credentials.push_back(cred);
    return S_OK;
}

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

    // RDP path: one tile, pre-filled with the forwarded name and password.
    if (_haveRemote)
    {
        if (SUCCEEDED(_AddCredential(_remoteUser.c_str(), _remotePassword.c_str(), nullptr)))
            _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;
    }

    DWORD count = 0;
    if (_pUserArray)
        _pUserArray->GetCount(&count);

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

        PWSTR qualified = nullptr;
        PWSTR sid       = nullptr;
        user->GetStringValue(PKEY_QualifiedUserName, &qualified);
        user->GetSid(&sid);

        if (qualified && sid && IsLocalAccount(qualified))
            _AddCredential(qualified, L"", sid);

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

    // If no local user tile was created (for example when Windows does not list
    // local users), add one generic tile with a username field. Without it the
    // filter could leave nobody a way to log on.
    if (_credentials.empty())
        _AddCredential(L"", L"", nullptr);

    _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;
    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));
        };

        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));
    }

    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)
{
    *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)
{
    _CreateCredentials();
    *pdwCount = static_cast<DWORD>(_credentials.size());
    *pdwDefault = _defaultIndex;
    *pbAutoLogonWithDefault = FALSE;
    return S_OK;
}

IFACEMETHODIMP CTacProvider::GetCredentialAt(DWORD dwIndex,
                                             ICredentialProviderCredential** ppcpc)
{
    if (!ppcpc)
        return E_INVALIDARG;
    _CreateCredentials();
    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 secret is saved per username and the demo only logs on against the local SAM.

There is one special case. On some machines, mostly domain joined ones, Windows doesn't give us any local users. Then there would be no tile at all, and with the filter nobody could log in. That's why the provider creates one tile with a username field in this case.

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 fallback 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 is there for a reason. 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.

The tile and the code check

CTacCredential.h:

cpp
#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);

    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:

cpp
#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)
{
    *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;
    *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;
    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: check the code, then serialize the password -----------

IFACEMETHODIMP CTacCredential::GetSerialization(
    CREDENTIAL_PROVIDER_GET_SERIALIZATION_RESPONSE* pcpgsr,
    CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcs,
    PWSTR* ppwszOptionalStatusText,
    CREDENTIAL_PROVIDER_STATUS_ICON* pcpsiOptionalStatusIcon)
{
    *pcpgsr = CPGSR_NO_CREDENTIAL_NOT_FINISHED;
    pcpcs->rgbSerialization = nullptr;
    pcpcs->cbSerialization  = 0;

    // 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 = tac::ResolveLocalUserSid(bare, sid);
    if (known && _pszSid && _wcsicmp(sid.c_str(), _pszSid) != 0)
        known = false;

    const std::wstring who     = bare + L" (" + (known ? sid : std::wstring(L"unknown account")) + L")";
    const std::wstring session = tac::DescribeSession();

    // --- the second factor ---
    std::wstring code = _rgFieldStrings[TFI_OTP] ? _rgFieldStrings[TFI_OTP] : L"";
    tac::OtpInfo info = {};
    tac::OtpResult result = known
        ? tac::VerifyOtp(sid, code, static_cast<uint64_t>(time(nullptr)), info)
        : tac::OtpResult::NotEnrolled;
    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,
        // so the screen never reveals who is enrolled. 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". 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 read or write the 2FA state of " + who +
                              L". The logon was refused. " + session + L".");
            break;
        }

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

    tac::LogEvent(EVENTLOG_INFORMATION_TYPE, tac::EVT_CODE_OK,
                  L"Valid one-time code for " + who + L" (time step " + std::to_wstring(info.step) +
                      L"). The password goes to LSA now. " + session + L".");

    // --- the ordinary password logon, once the code is valid ---
    PWSTR pwzPassword = nullptr;
    HRESULT hr = ProtectIfNecessaryAndCopyPassword(_rgFieldStrings[TFI_PASSWORD], _cpus, &pwzPassword);

    // Always log on as COMPUTERNAME\user. This is a local-account demo, so we
    // never authenticate a domain the user might have typed.
    WCHAR computer[MAX_COMPUTERNAME_LENGTH + 1] = {};
    DWORD cchComputer = ARRAYSIZE(computer);
    if (SUCCEEDED(hr) && !GetComputerNameW(computer, &cchComputer))
        hr = HRESULT_FROM_WIN32(GetLastError());

    std::wstring qualified = std::wstring(computer) + L"\\" + bare;

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

    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. If it failed, clear password and
// code like the built-in tile does (the code is used up anyway), and turn a
// wrong password into a readable message.
IFACEMETHODIMP CTacCredential::ReportResult(NTSTATUS ntsStatus, NTSTATUS,
                                            PWSTR* ppwszOptionalStatusText,
                                            CREDENTIAL_PROVIDER_STATUS_ICON* pcpsiOptionalStatusIcon)
{
    *ppwszOptionalStatusText = nullptr;
    *pcpsiOptionalStatusIcon = CPSI_NONE;

    if (ntsStatus != 0)
    {
        _ResetField(TFI_PASSWORD);
        _ResetField(TFI_OTP);
    }

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

GetSerialization is the most important function of the whole project. LogonUI calls it when the user clicks submit. This is the only place where we can stop the logon.

I made some decisions here that I want to explain.

The code is checked first. If it 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.

This has an advantage and a disadvantage. The advantage is that without a valid code the password is never sent. Someone at the logon screen cannot test passwords with our tile. The disadvantage is that a wrong code never reaches LSA. The lockout policy does not 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. "Not enrolled", "wrong code" and "code already used" show the same text. Otherwise anyone at the logon screen could find out which accounts have 2FA. 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 types 5 wrong codes for an account learns that it is enrolled. On a user tile the account is visible anyway, 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 fallback 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 the code is checked, the name from the tile is resolved to a SID and both are compared. If they are not the same, the code is not checked at all. 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 code, used code, lock, unknown account and success. Each event has the account, the SID and where the attempt came from: console or RDP with the IP address. The code itself is 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 password, 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 the code is 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. After a failed logon (ReportResult) both fields are emptied, like on the normal Windows tile. 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.

Rate limiting and replay protection

The code check itself is in ValidateTotp. But a check alone is not enough. Two things 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 remember: the Windows lockout policy never sees any of these guesses, because a wrong code never reaches LSA. There isn't even an 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):

cpp
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:

cpp
#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.
    OtpResult VerifyOtp(const std::wstring& sid, const std::wstring& code,
                        uint64_t now, OtpInfo& info);
}

verify.cpp:

cpp
#include "verify.h"
#include "store.h"
#include "totp.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 = {};

        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;
    }
}

Some decisions I want to explain.

While the account is locked, the code is not checked at all. This is the most important part. 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 step is burned before the password is checked. The state is saved in GetSerialization, before the password goes to LSA. If the password is wrong, the code is gone and the user has to wait for the next one. That's a bit annoying. But the other way around is 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. Anyone who can reach the tile can lock an account with 5 wrong codes. This is the same with the normal Windows lockout policy. At the console someone has to be in front of the machine. For RDP it depends on NLA: with NLA the attacker needs the password to even see our tile. Without NLA anybody who reaches port 3389 can lock your users. So leave NLA on. Unlike the Windows lockout, my lock also hits the built-in Administrator. If your break-glass account is locked, another admin runs enroll.exe /unlock, or you wait at most 60 minutes.

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. Reading and writing the state is not atomic across these processes. If an attacker sends guesses in several sessions in exactly the same millisecond, each of them can get one extra guess past the lock. It doesn't get worse than that, because the lock is written after every guess. A real product does the check in one central Windows service. I didn't want an extra service for the demo. 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. Named objects shared across sessions are only safe in a private namespace, and that is more code than the problem is worth here.

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
100InformationValid code, the password goes to LSA
101WarningWrong code
102WarningCode was already used
103WarningAttempt while the account is locked
104ErrorThis wrong code locked the account
105WarningAccount unknown or not enrolled
106ErrorState could not be read or written, logon refused

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 of the client. The code itself is never logged, not even a wrong one. A wrong code is often just a typo of the right one.

eventlog.h:

cpp
#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,
    };

    // 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 from 192.168.1.50, session 3".
    std::wstring DescribeSession();
}

eventlog.cpp:

cpp
#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.
        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.
                wchar_t ip[16];
                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 from %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 simply 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 simply 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 106.

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.

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:

cpp
#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(); }
    LONG _cRef;
};

CTacFilter.cpp:

cpp
#include "CTacFilter.h"
#include "guid.h"
#include "dll.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. Leave the other
// scenarios (CredUI, change password, PLAP) alone, or we 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;

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

// 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.
IFACEMETHODIMP CTacFilter::UpdateRemoteCredential(
    const CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcsIn,
    CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcsOut)
{
    if (!pcpcsIn || pcpcsIn->cbSerialization == 0 || !pcpcsIn->rgbSerialization)
        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.

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.

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). Then it has to be decrypted first. 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

The rest is infrastructure.

helpers.cpp is a shortened version of the helper functions from the Microsoft Credential Provider sample (Windows-classic-samples, Security/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:

cpp
#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, Security/CredentialProvider).

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

HRESULT ProtectIfNecessaryAndCopyPassword(PCWSTR pwzPassword,
                                          CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
                                          PWSTR* ppwzProtectedPassword);

// 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:

cpp
#include "helpers.h"
#include <shlwapi.h>
#include <strsafe.h>

#define SECURITY_WIN32
#include <security.h>

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

static void InitUnicodeString(UNICODE_STRING* pus, PWSTR pwz)
{
    // A UNICODE_STRING length is a USHORT (bytes). Cap the character count so
    // len * sizeof(WCHAR) can never wrap to a small value while Buffer still
    // points at the full string. No real password comes close to this.
    size_t len = pwz ? wcslen(pwz) : 0;
    const size_t maxChars = (0xFFFF / sizeof(WCHAR)) - 1;   // 32766
    if (len > maxChars)
        len = maxChars;

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

// 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;
}

HRESULT ProtectIfNecessaryAndCopyPassword(PCWSTR pwzPassword,
                                          CREDENTIAL_PROVIDER_USAGE_SCENARIO,
                                          PWSTR* ppwzProtectedPassword)
{
    // The Microsoft sample runs the password through CredProtect first. A plain
    // copy works for a local logon and keeps this demo small.
    *ppwzProtectedPassword = nullptr;
    return SHStrDupW(pwzPassword ? pwzPassword : L"", ppwzProtectedPassword);
}

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;
    }

    InitUnicodeString(&pkil->LogonDomainName, pwzDomain);
    InitUnicodeString(&pkil->UserName, pwzUsername);
    InitUnicodeString(&pkil->Password, pwzPassword);
    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 just copies the password. The Microsoft sample encrypts it with CredProtect. For a local logon this works fine and the code stays smaller.

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:

cpp
#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:

cpp
#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:

cpp
#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 (!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 g_cRef == 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 just doesn't load the DLL. You don't get an error, the tile simply doesn't show up.

build.bat:

bat
@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.
REM =====================================================================

setlocal
cd /d "%~dp0"

echo.
echo === Building TacProvider.dll (credential provider + filter) ===
cl /nologo /LD /MT /EHsc /std:c++17 /W3 /DUNICODE /D_UNICODE ^
   dll.cpp CTacProvider.cpp CTacCredential.cpp CTacFilter.cpp ^
   helpers.cpp totp.cpp store.cpp verify.cpp eventlog.cpp ^
   /Fe:TacProvider.dll ^
   /link /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 /EHsc /std:c++17 /W3 /DUNICODE /D_UNICODE ^
   enroll.cpp totp.cpp store.cpp ^
   /Fe:enroll.exe ^
   /link 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>")

# --- the credential provider + filter DLL ---
add_library(TacProvider SHARED
    dll.cpp
    CTacProvider.cpp
    CTacCredential.cpp
    CTacFilter.cpp
    helpers.cpp
    totp.cpp
    store.cpp
    verify.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)

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 everything 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.

[-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]

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 fallback 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. 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:

Then 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 the password never reaches Windows:

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.

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

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 enrolledSame message as in test 2, no logon
4Enrolled user, correct code, wrong password"Incorrect password.", password and code fields are empty again
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
135 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"

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, 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\.

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 tip again: boot into WinRE, edit the registry offline, delete the filter. This works 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 controls how Windows starts doesn't have to talk to the tile at all. This chapter is about the person in front of the machine.

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). Ransomware uses it to start without the EDR. For us it means the same thing: a security component that only runs in normal mode doesn't run in Safe 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 is decoration. 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.
  • Anyone who reaches the tile can lock an account with 5 wrong codes. Leave NLA on for RDP, then this needs the password.
  • The lock counter is not atomic across parallel sessions. With several RDP sessions at the same moment an attacker gets one extra guess per session and lock.
  • 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 fallback 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 all logon types, the check has to be in LSA. This is done with a custom authentication package or an SSP/AP. AuthLite does exactly that.

Real products also don't do the check inside LogonUI. A central service makes the lock counter atomic, 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 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.

Maybe I will try that in another article.

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.

Esc

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