Repository navigation
Expand file tree
/
Copy pathenroll.cpp
More file actions
380 lines (341 loc) · 13.4 KB
/
Copy pathenroll.cpp
File metadata and controls
380 lines (341 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
#include "totp.h"
#include "store.h"
#include "statelock.h"
#include <bcrypt.h>
#include <sddl.h>
#include <conio.h>
#include <cctype>
#include <cstdio>
#include <ctime>
#include <string>
#include <vector>
#pragma comment(lib, "bcrypt.lib")
#pragma comment(lib, "advapi32.lib")
// The otpauth label is UTF-8 and percent-encoded, so names like "jürg" survive.
static std::string UrlEncode(const std::wstring& text)
{
int cb = WideCharToMultiByte(CP_UTF8, 0, text.c_str(), -1, nullptr, 0, nullptr, nullptr);
std::string utf8(cb > 1 ? cb - 1 : 0, '\0');
if (cb > 1)
WideCharToMultiByte(CP_UTF8, 0, text.c_str(), -1, &utf8[0], cb, nullptr, nullptr);
static const char hex[] = "0123456789ABCDEF";
std::string out;
for (unsigned char c : utf8)
{
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~')
out += static_cast<char>(c);
else
{
out += '%';
out += hex[c >> 4];
out += hex[c & 0x0F];
}
}
return out;
}
// Resets the lock of an enrolled user. The last used time step stays, so an
// unlock never makes an old code valid again.
static int Unlock(const std::wstring& user, const std::wstring& sid)
{
tac::StateLock lock;
if (!lock.Held())
{
wprintf(L"Could not take the state lock. Run this elevated, from the install folder.\n");
return 1;
}
tac::UserState state;
if (!tac::LoadState(sid, state))
{
wprintf(L"Could not read the state of '%s'. Run this from an elevated prompt.\n", user.c_str());
return 1;
}
uint64_t now = static_cast<uint64_t>(time(nullptr));
wprintf(L"'%s': %u wrong code(s) in a row, %s.\n", user.c_str(), state.failures,
state.lockedUntil > now ? L"locked" : L"not locked");
state.failures = 0;
state.lockedUntil = 0;
if (!tac::SaveState(sid, state))
{
wprintf(L"Could not reset the state. Run this from an elevated prompt.\n");
return 1;
}
wprintf(L"Unlocked.\n");
return 0;
}
// Removes an enrollment: the secret, the state and the RID and name indexes the
// LSA packages read. The account does not have to exist anymore. If it was already
// deleted in Windows we cannot resolve a SID, but we can still drop the name
// entry, and that is the one that would otherwise keep denying network logons.
static int Remove(const std::wstring& user, const std::wstring& sid)
{
const std::wstring& shown = user.empty() ? sid : user;
if (sid.empty())
wprintf(L"'%s' is not a local user account (anymore). Removing the name entry only.\n"
L"For the secret of a deleted account, use the SID from 'enroll /list'.\n",
user.c_str());
if (!tac::RemoveEnrollment(user, sid))
{
wprintf(L"Could not remove the enrollment of '%s'. Run this from an elevated prompt.\n",
shown.c_str());
return 1;
}
wprintf(L"Removed the enrollment of '%s'.\n", shown.c_str());
wprintf(L"The account can log on with its password alone again, unless you also\n");
wprintf(L"set the deny rights (deny-noninteractive.ps1).\n");
return 0;
}
// Lists every stored secret, the account it belongs to and whether the two
// indexes the LSA packages read know about it. A secret whose SID no longer
// resolves belongs to a deleted account: it can never authenticate, but it
// still counts as "enrolled" for the filter, so remove it by SID.
static int List()
{
std::vector<tac::EnrollmentEntry> entries;
if (!tac::ListEnrollments(entries))
{
wprintf(L"Could not read the enrollments. Run this from an elevated prompt.\n");
return 1;
}
if (entries.empty())
{
wprintf(L"No account is enrolled. The filter hides nothing.\n");
return 0;
}
bool orphans = false;
for (const tac::EnrollmentEntry& e : entries)
{
const bool gone = e.name.empty();
orphans = orphans || gone;
wprintf(L" %-20s %s RID index: %s name index: %s\n",
gone ? L"(deleted account)" : e.name.c_str(), e.sid.c_str(),
e.ridIndexed ? L"yes" : L"NO",
gone ? L"-" : (e.nameIndexed ? L"yes" : L"NO"));
}
if (orphans)
{
wprintf(L"\nA deleted account still counts as enrolled, so the filter keeps hiding the\n");
wprintf(L"other sign-in options. Remove it with: enroll /remove <SID>\n");
}
return 0;
}
// Rebuilds the RID and name indexes the LSA packages read, from the stored
// secrets. Needed once after updating from a version without the RID index,
// and after renaming an enrolled account.
static int Reindex()
{
int count = tac::ReindexEnrollments();
if (count < 0)
{
wprintf(L"Could not read the enrollments or write the index. Run this from an elevated prompt.\n");
return 1;
}
wprintf(L"Indexed %d enrollment(s).\n", count);
return 0;
}
// Reads one line from the console, at most `max` characters.
static std::wstring ReadLine(size_t max)
{
wchar_t buf[64] = {};
if (!fgetws(buf, ARRAYSIZE(buf), stdin))
return std::wstring();
std::wstring line(buf);
SecureZeroMemory(buf, sizeof(buf));
while (!line.empty() && (line.back() == L'\n' || line.back() == L'\r' || line.back() == L' '))
line.pop_back();
if (line.size() > max)
line.clear();
return line;
}
// Asks for a code from the authenticator app before anything is stored. A
// secret that was mistyped into the app would otherwise lock the account out
// at the next logon. On success `step` is the time step of the code, so the
// same code cannot be used again at the logon screen.
static bool ConfirmSecret(const std::string& secret, uint64_t& step)
{
std::vector<BYTE> key;
if (!tac::Base32Decode(secret, key))
return false;
bool ok = false;
for (int attempt = 1; attempt <= 3 && !ok; ++attempt)
{
wprintf(L"Enter the 6-digit code from your authenticator app (attempt %d of 3): ", attempt);
std::wstring code = ReadLine(16);
ok = tac::ValidateTotp(key, code, static_cast<uint64_t>(time(nullptr)), 0, step);
if (!code.empty())
SecureZeroMemory(&code[0], code.size() * sizeof(WCHAR));
if (!ok)
wprintf(L"That code does not match. Check the secret in the app and the clock of both devices.\n");
}
SecureZeroMemory(key.data(), key.size());
return ok;
}
// Deletes every secret, state and index entry. For a full uninstall; the
// provider registration itself is removed by unregister.reg.
static int Purge()
{
bool any = false;
if (tac::QueryAnyEnrollment(any) && any)
{
wprintf(L"This deletes ALL 2FA secrets and state on this machine. If the filter is\n");
wprintf(L"still registered, the other sign-in options come back immediately.\n");
wprintf(L"Continue? [y/N] ");
int answer = _getwch();
wprintf(L"\n");
if (answer != L'y' && answer != L'Y')
{
wprintf(L"Aborted.\n");
return 0;
}
}
if (!tac::PurgeAllEnrollments())
{
wprintf(L"Could not delete HKLM\\SOFTWARE\\TheAdminCafe\\2FA. Run this from an elevated prompt.\n");
return 1;
}
wprintf(L"All 2FA data removed.\n");
return 0;
}
// Parses a SID typed on the command line and returns it in the canonical form
// the store uses as value name ("S-1-5-21-..."). Stricter than the "S-1-"
// prefix check, so a local user who happens to be called "S-1-x" is not
// mistaken for one, and "s-1-5-..." in lower case still finds its entries.
static bool CanonicalSid(const wchar_t* text, std::wstring& sid)
{
sid.clear();
PSID psid = nullptr;
if (!ConvertStringSidToSidW(text, &psid))
return false;
PWSTR canonical = nullptr;
const BOOL ok = ConvertSidToStringSidW(psid, &canonical);
LocalFree(psid);
if (!ok)
return false;
sid = canonical;
LocalFree(canonical);
return true;
}
int wmain(int argc, wchar_t** argv)
{
if (argc == 2 && _wcsicmp(argv[1], L"/list") == 0)
return List();
if (argc == 2 && _wcsicmp(argv[1], L"/purge") == 0)
return Purge();
const bool reindex = argc == 2 && _wcsicmp(argv[1], L"/reindex") == 0;
const bool unlock = argc == 3 && _wcsicmp(argv[1], L"/unlock") == 0;
const bool remove = argc == 3 && _wcsicmp(argv[1], L"/remove") == 0;
if (!reindex &&
((argc != 2 && !unlock && !remove) || (argc == 2 && argv[1][0] == L'/')))
{
wprintf(L"Usage: enroll <local username>\n");
wprintf(L" enroll /unlock <local username>\n");
wprintf(L" enroll /remove <local username | SID>\n");
wprintf(L" enroll /list (enrolled accounts and index state)\n");
wprintf(L" enroll /reindex\n");
wprintf(L" enroll /purge (delete all 2FA data)\n");
return 1;
}
// Repair the ACL of store keys an older version created with a weaker one.
// This used to happen on every state write, i.e. on every logon attempt;
// it needs WRITE_DAC and a hive write and belongs here instead.
if (!tac::EnsureStoreAcls())
wprintf(L"Warning: could not re-apply the ACL to the existing 2FA registry keys.\n");
if (reindex)
return Reindex();
const wchar_t* arg = argv[(unlock || remove) ? 2 : 1];
// Only names that exist as a local user account on this machine. The
// secret is stored under the SID of that account.
std::wstring user = tac::NormalizeUser(arg);
std::wstring sid;
if (!tac::ResolveLocalUserSid(user, sid))
{
// For /remove an unresolvable name is not fatal: the name-keyed index
// entry can and should still go. A SID (from `enroll /list`) removes
// the secret of an account that was already deleted. Everything else
// needs a live account.
if (!remove)
{
wprintf(L"'%s' is not a local user account on this machine.\n", user.c_str());
return 1;
}
if (CanonicalSid(arg, sid))
user.clear();
else
sid.clear();
if (user.empty() && sid.empty())
{
wprintf(L"'%s' is neither a user name nor a SID.\n", arg);
return 1;
}
}
if (unlock)
return Unlock(user, sid);
if (remove)
return Remove(user, sid);
std::vector<BYTE> existing;
if (tac::LoadSecretKey(sid, existing))
{
SecureZeroMemory(existing.data(), existing.size());
wprintf(L"'%s' is already enrolled. Replace the secret? [y/N] ", user.c_str());
int answer = _getwch();
wprintf(L"\n");
if (answer != L'y' && answer != L'Y')
{
wprintf(L"Aborted.\n");
return 0;
}
}
// 160 bits, the key size RFC 4226 recommends for HMAC-SHA1.
BYTE raw[20];
if (BCryptGenRandom(nullptr, raw, sizeof(raw), BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0)
{
wprintf(L"Could not generate a random secret.\n");
return 1;
}
std::string secret = tac::Base32Encode(raw, sizeof(raw));
SecureZeroMemory(raw, sizeof(raw));
std::string uri = "otpauth://totp/TheAdminCafe:" + UrlEncode(user) +
"?secret=" + secret +
"&issuer=TheAdminCafe&algorithm=SHA1&digits=6&period=30";
// Show the secret first and store it only after a code from the app
// matches. Until then nothing is written, and an existing enrollment
// keeps working.
wprintf(L"\nNew secret for '%s' (%s):\n\n", user.c_str(), sid.c_str());
wprintf(L" Secret : %S\n", secret.c_str());
wprintf(L" URI : %S\n\n", uri.c_str());
wprintf(L"Add the secret to your authenticator app. Don't paste it into an online\n");
wprintf(L"QR code generator.\n\n");
SecureZeroMemory(&uri[0], uri.size());
uint64_t confirmedStep = 0;
if (!ConfirmSecret(secret, confirmedStep))
{
wprintf(L"\nNot enrolled: no matching code. Nothing was changed.\n");
SecureZeroMemory(&secret[0], secret.size());
return 1;
}
// A new secret starts with a clean state: no lock, and the confirmation
// code counts as used. The two steps are reported separately, because "the
// secret is stored but the old state is still there" is a different
// situation from "nothing was written" and needs a different fix.
if (!tac::StoreSecret(sid, secret))
{
wprintf(L"Could not store the secret. Run this from an elevated prompt.\n");
wprintf(L"An earlier enrollment of '%s', if any, is unchanged.\n", user.c_str());
SecureZeroMemory(&secret[0], secret.size());
return 1;
}
SecureZeroMemory(&secret[0], secret.size());
tac::UserState fresh = {};
fresh.lastStep = confirmedStep;
tac::StateLock lock;
if (!lock.Held() || !tac::SaveState(sid, fresh))
{
wprintf(L"The secret was stored, but the old lockout state of '%s' could not be\n", user.c_str());
wprintf(L"reset. Run 'enroll /unlock %s' from an elevated prompt.\n", user.c_str());
return 1;
}
wprintf(L"\nEnrolled '%s'. Test the logon now, before you import the filter.\n", user.c_str());
wprintf(L"Wait for the NEXT code in the app: the one you just typed is used up, and\n");
wprintf(L"typing it again at the logon screen counts as a wrong code.\n");
wprintf(L"Clear this window afterwards (cls), the secret is still in the scrollback.\n");
return 0;
}