rustio-admin 0.20.0

Django Admin, but for Rust. A small, focused admin framework.
Documentation
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
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
//! TOTP multi-factor authentication (R3).
//!
//! See `DESIGN_R3_MFA.md` for the canonical contract this module
//! implements. R3 ships in 0.7.0; this module owns the MFA
//! runtime — TOTP enrolment + verification, backup-code
//! generation + consumption + regeneration, MFA disable, and the
//! AES-256-GCM secret-encryption helpers. The HTTP wrappers will
//! live in `admin::mfa_handlers`; routes are registered in
//! `admin::routes::register_admin_routes` after R2's
//! admin-recovery routes. The testcontainers integration suite
//! under `tests/integration_*.rs` exercises the DB-touching paths
//! end-to-end against an ephemeral Postgres, gated behind
//! `--features integration-test` per `DESIGN_R3_MFA.md` §13.3.
//!
//! ## Visibility note
//!
//! Items here are `pub` (rather than `pub(crate)`) so the
//! `crate::__integration` re-export module can re-export them
//! under the `integration-test` feature. The MODULE itself is
//! `pub(crate)` (`auth::mod`), so the canonical path
//! `rustio_admin::auth::mfa::*` remains closed to external
//! callers — `__integration` is the only door, and it is
//! itself feature-gated + `#[doc(hidden)]`. Same pattern as
//! `auth::recovery_admin`.
//!
//! ## What lives here today
//!
//! - [`migrate_user_mfa_schema`] — adds the additive R3 columns
//!   on `rustio_users` (`mfa_enabled`, `mfa_secret_ciphertext`,
//!   `mfa_secret_key_id`, `mfa_last_used_step`) plus the new
//!   `rustio_mfa_backup_codes` table and a per-user partial
//!   index on `(user_id) WHERE used_at IS NULL` for the
//!   verification-path scan (§7 of the design doc). R3 commit #1.
//! - [`MfaPolicy`] — the four-variant enum that controls
//!   framework-wide MFA enforcement (§11.1 of the design doc).
//!   `Disabled` / `Optional` (default) / `Required` /
//!   `RequiredForRoles(&[Role])`. The variant is data-only at
//!   this commit; the `login_guard` routing that consults it
//!   lands in a later commit (§12.3). Wired onto `Admin` via
//!   [`crate::admin::types::Admin::require_mfa`]. R3 commit #2.
//! - [`MfaKey`] / [`wrap_secret`] / [`unwrap_secret`] —
//!   AES-256-GCM secret encryption helpers (§8.1 of the design
//!   doc, D1). The plaintext TOTP secret is encrypted before it
//!   reaches the database; storage layout is `nonce ||
//!   ciphertext || tag`. `MfaKey::from_env` reads
//!   `RUSTIO_SECRET_KEY` (32-byte URL-safe-base64); the boot
//!   refusal when `MfaPolicy != Disabled` and the env var is
//!   unset lands in a later commit. Round-trip + tamper +
//!   wrong-key detection are pinned by unit tests. R3 commit #3.
//! - [`generate_backup_codes`] / [`hash_backup_code`] /
//!   [`verify_backup_code`] / [`normalise_backup_code`] +
//!   [`BACKUP_CODE_COUNT`] / [`BACKUP_CODE_LEN`] — the
//!   backup-code surface (§8.1, D2 + D7). 8 codes per batch in
//!   the locked `XXXX-XXXX` format from the 31-char
//!   ambiguity-stripped alphabet (no `0/O/1/I/L`); Argon2id with
//!   low-memory params (`m = 16 MiB`, `t = 2`, `p = 1`); the
//!   normalise function uppercases and strips the hyphen so the
//!   user can copy with or without the separator. Every helper
//!   is marked `#[allow(dead_code)]` until the enrolment +
//!   verification runtime wires the call sites in R3 commits
//!   #6 and #7. R3 commit #4.
//! - [`current_step`] / [`generate_totp`] / [`verify_totp`] —
//!   hand-rolled RFC 6238 TOTP (§9.4). HMAC-SHA1 (the
//!   authenticator-app-default algorithm; `algorithm=SHA256`
//!   variants exist but interop is best with SHA-1), 30-second
//!   step interval, ±1 step skew tolerance (per Appendix B
//!   locked decisions). 6-digit codes per industry standard.
//!   `verify_totp` returns the step that matched on success so
//!   the caller can stamp `mfa_last_used_step` for replay
//!   protection (D4). Pinned by the canonical RFC 6238
//!   Appendix B test vectors (truncated from 8-digit to 6-digit
//!   per authenticator-app standard). R3 commit #5.
//! - [`provision_secret`] / [`ProvisionedSecret`] /
//!   [`confirm_enrolment`] / [`EnrolOutcome`] — the enrolment
//!   runtime (§4.1, §9). `provision_secret` is pure: 20 random
//!   bytes for RFC 6238 + base32 encoding for the QR / manual
//!   entry display. `confirm_enrolment` is the DB-touching
//!   path: verifies the user's first TOTP code, AES-GCM
//!   encrypts the secret, stores the row, generates +
//!   Argon2id-hashes 8 backup codes, INSERTs them, and emits
//!   `AuditEvent::MfaEnabled`. The first MFA function that
//!   touches `rustio_users.mfa_enabled` and writes
//!   `rustio_mfa_backup_codes`. The HTTP handler that calls it
//!   lands in a later commit. R3 commit #6.
//! - [`verify_totp_for_user`] / [`VerifyOutcome`] — the TOTP
//!   verification runtime (§4.2, D4). Reads the encrypted
//!   secret + `mfa_last_used_step` from the user row,
//!   decrypts via [`unwrap_secret`], runs [`verify_totp`]
//!   against the candidate, and rejects steps at or below
//!   the stored value (D4 replay protection). On success
//!   stamps the new step. No audit row — TOTP success is
//!   captured via the session-promotion `parent_session_id`
//!   lineage per §8.3, not a separate event. R3 commit #7.
//! - [`consume_backup_code`] / [`BackupConsumeOutcome`] — the
//!   backup-code consume runtime (§4.4, D7). Argon2id-verifies
//!   the candidate against every unused row for the user
//!   (constant-time iteration), atomically marks the matching
//!   row `used_at = NOW()`, emits
//!   `AuditEvent::MfaCodeConsumed` with metadata
//!   `{ code_id, remaining_codes, via }`. Single-use enforced
//!   at the index level + a conditional UPDATE that races
//!   safely. R3 commit #8.
//! - [`disable_mfa`] / [`DisableOutcome`] — the self-disable
//!   runtime (§4.3). Clears all four MFA columns on the user
//!   row, deletes every backup-code row, calls
//!   `auth::sessions::invalidate_sessions` with
//!   `SessionInvalidationReason::MfaDisabled` (Doctrine 22's
//!   sole writer of `revoked_at`), and emits
//!   `AuditEvent::MfaDisabled`. The first R3 runtime that
//!   goes through `invalidate_sessions` — the substrate
//!   carries through unchanged. R3 commit #9.
//! - [`regenerate_backup_codes`] / [`RegenOutcome`] — the
//!   regenerate runtime (§4.5, D3). Atomic transaction:
//!   `SELECT … FOR UPDATE` on the user row to serialise
//!   concurrent regenerates, DELETE every existing
//!   backup-code row, INSERT 8 fresh hashed rows, then commit.
//!   Emits the new `AuditEvent::BackupCodesRegenerated`
//!   variant with metadata
//!   `{ previous_codes_invalidated, new_codes_count }`.
//!   D3 enforced at the SQL level — the old batch is
//!   unrecoverable from the moment the transaction commits.
//!   R3 commit #10.
//! - [`promote_session_to_mfa_verified`] — the trust-escalation
//!   primitive (`DESIGN_SESSIONS.md` §11, Doctrine 17). Mints
//!   a fresh `mfa_verified` session row with
//!   `parent_session_id` pointing at the caller's current
//!   row, then revokes the parent via
//!   `auth::sessions::invalidate_sessions` with
//!   `SessionInvalidationReason::TrustEscalation`. Returns the
//!   new plaintext token for the caller (handler) to set as
//!   the cookie. Used by the verify POST handler (later
//!   commit) after `verify_totp_for_user` or
//!   `consume_backup_code` returns success. R3 commit #11.
//!
//! Subsequent commits will add the HTTP handlers, route
//! registration, and `MfaPolicy` routing into `login_guard`
//! (§10, §12.3).
//!
//! ## Doctrine 22 reminder
//!
//! Centralised invalidation remains the single writer of
//! `revoked_at` on `rustio_sessions`. R3 will pass `MfaEnabled`
//! and `MfaDisabled` reasons to
//! [`crate::auth::sessions::invalidate_sessions`] when the
//! enrolment / disable runtime lands; nothing in this module
//! writes to `revoked_at` directly. See `DESIGN_SESSIONS.md`
//! Doctrine 22 for the grep proof contract.
//!
//! ## At-rest secrecy reminder
//!
//! TOTP secrets are encrypted with AES-256-GCM keyed by
//! `RUSTIO_SECRET_KEY` before persisting (D1 of the R3 design
//! doc). Backup codes are Argon2id-hashed with low-memory params
//! (D2). Plaintext TOTP secrets and plaintext backup codes
//! exist only in process memory during enrolment + verification.
//! The schema column `mfa_secret_ciphertext BYTEA` carries the
//! AEAD output (`nonce || ciphertext || tag`); the
//! `code_hash TEXT` column carries the Argon2id hash. The
//! schema enforced here is the persistence contract for those
//! invariants.
//!
//! Idempotent. Safe to call on every boot. `auth::init_tables`
//! invokes [`migrate_user_mfa_schema`] after R2's
//! `recovery_admin::migrate_user_lockout_schema`.

use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Key as GcmKey, Nonce};
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::{Algorithm, Argon2, Params, Version};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use chrono::{Duration as ChronoDuration, Utc};
use hmac::{Hmac, Mac};
use rand::{Rng, RngCore};
use sha1::Sha1;

use crate::admin::audit::{record as audit_record, ActionType, AuditEvent, LogEntry};
use crate::admin::builtin::client_ip;
use crate::auth::sessions::{
    hash_token_for_storage, invalidate_sessions, random_token, SessionInvalidationReason,
    SessionTarget,
};
use crate::auth::Role;
use crate::error::{Error, Result};
use crate::http::Request;
use crate::orm::Db;

/// Session lifetime for trust-escalated `mfa_verified` rows,
/// matching the framework's `SESSION_LENGTH_DAYS` constant in
/// `auth::sessions`. Kept local here so this module does not
/// reach across to `pub(crate)` consts; if the canonical
/// constant ever moves out of `pub(crate)` scope it should be
/// imported in place of this local copy.
const MFA_VERIFIED_SESSION_DAYS: i64 = 14;

type HmacSha1 = Hmac<Sha1>;

// internal:
/// AES-256-GCM key material for TOTP secret encryption (D1).
///
/// 32 raw bytes — the AES-256 key. Constructed from the
/// `RUSTIO_SECRET_KEY` environment variable (32-byte
/// URL-safe-base64-encoded) via [`MfaKey::from_env`], or from
/// raw bytes via [`MfaKey::from_bytes`] (for tests / explicit
/// construction).
///
/// `Clone` is intentional — the key is held by the framework's
/// `MfaSecretKeyResolver` (future commit) and cloned cheaply
/// onto cipher instances per-encryption. `Copy` is intentionally
/// NOT derived: a `Copy` key would silently scatter copies into
/// every stack frame that touches it; an explicit `.clone()`
/// makes key usage auditable on review.
///
/// Plaintext key material lives only in process memory. The
/// `Drop` is a no-op intentionally — the operating system zeroes
/// freed pages on most production deployments, and the framework
/// does not promise constant-time secure-erase on Drop. Operators
/// who require zeroize-on-drop semantics can wrap this type in
/// the `zeroize` crate's `Zeroizing` shim at the construction
/// site.
#[derive(Clone)]
#[allow(dead_code)] // call sites land in R3 commit #6+ (enrol / verify runtime)
pub struct MfaKey([u8; 32]);

#[allow(dead_code)] // see MfaKey type comment — light up in R3 commit #6+
impl MfaKey {
    // internal:
    /// Read the framework-wide secret key from the
    /// `RUSTIO_SECRET_KEY` environment variable.
    ///
    /// The variable carries 32 raw key bytes, encoded as
    /// URL-safe-base64 without padding. After decoding the
    /// constructor verifies the byte length is exactly 32.
    ///
    /// **Failure modes** (all surface as `Error::Internal` —
    /// the failure happens at boot, not at request time):
    ///
    /// - Env var unset.
    /// - Decode failure (invalid URL-safe-base64 alphabet,
    ///   stray padding, etc.).
    /// - Wrong length after decode (≠ 32 bytes).
    ///
    /// The boot guard that ties this requirement to
    /// `MfaPolicy != Disabled` is wired in a later commit; this
    /// constructor reports the failure but does NOT enforce
    /// "policy says Disabled, so missing key is fine."
    pub(crate) fn from_env() -> Result<Self> {
        let raw = std::env::var("RUSTIO_SECRET_KEY").map_err(|_| {
            Error::Internal(
                "RUSTIO_SECRET_KEY env var is unset; required when MfaPolicy != Disabled".into(),
            )
        })?;
        let decoded = URL_SAFE_NO_PAD.decode(raw.trim()).map_err(|e| {
            Error::Internal(format!(
                "RUSTIO_SECRET_KEY is not valid URL-safe-base64 (no padding): {e}"
            ))
        })?;
        let bytes: [u8; 32] = decoded.as_slice().try_into().map_err(|_| {
            Error::Internal(format!(
                "RUSTIO_SECRET_KEY decodes to {} bytes; AES-256 requires exactly 32",
                decoded.len()
            ))
        })?;
        Ok(Self(bytes))
    }

    // internal:
    /// Construct from raw 32 bytes. Used by tests and explicit
    /// project wiring (e.g. a project that derives the key from
    /// AWS KMS / HashiCorp Vault rather than an env var).
    pub fn from_bytes(bytes: [u8; 32]) -> Self {
        Self(bytes)
    }

    /// Borrow the 32-byte key for the AES-256-GCM cipher's
    /// `KeyInit`. The reference is bounded to the borrow's
    /// lifetime; callers cannot retain it past the cipher
    /// construction.
    fn as_bytes(&self) -> &[u8; 32] {
        &self.0
    }
}

// internal:
/// Encrypt `plaintext` under `key` with AES-256-GCM.
///
/// Returns the on-disk byte layout: `nonce (12 bytes) ||
/// ciphertext || auth_tag (16 bytes)`. The nonce is generated
/// fresh per call from `rand::thread_rng()`.
///
/// **Output length** is `12 + plaintext.len() + 16`, exactly the
/// shape persisted in `rustio_users.mfa_secret_ciphertext` per
/// `DESIGN_R3_MFA.md` §8.1. Callers do not need to track the
/// nonce separately — it travels with the ciphertext.
///
/// **Infallible.** AEAD encryption with `aes-gcm` cannot fail
/// for in-memory plaintexts; the method that returns `Result` on
/// the underlying API exists for streaming-mode callers we do
/// not use. Returning `Vec<u8>` directly keeps the call sites
/// simple.
#[allow(dead_code)] // call site lands in R3 commit #6 (enrol_secret runtime)
pub(crate) fn wrap_secret(plaintext: &[u8], key: &MfaKey) -> Vec<u8> {
    let mut nonce_bytes = [0u8; 12];
    rand::thread_rng().fill_bytes(&mut nonce_bytes);
    let nonce = Nonce::from_slice(&nonce_bytes);

    let cipher = Aes256Gcm::new(GcmKey::<Aes256Gcm>::from_slice(key.as_bytes()));
    let ciphertext = cipher
        .encrypt(nonce, plaintext)
        .expect("AES-256-GCM encrypt cannot fail for in-memory plaintext");

    let mut out = Vec::with_capacity(12 + ciphertext.len());
    out.extend_from_slice(&nonce_bytes);
    out.extend_from_slice(&ciphertext);
    out
}

// internal:
/// Decrypt `input` (`nonce || ciphertext || tag`) under `key`.
///
/// **Failure modes** (all surface as `Error::Internal` — the
/// recovery is operator-side; the user surface treats this as
/// "session invalid" via the verify handler's outcome mapping):
///
/// - Input shorter than 28 bytes (no room for nonce + tag).
/// - AEAD verification failure: tampered ciphertext, wrong key,
///   nonce reuse on a different message, etc. The library does
///   not distinguish between these — they all reduce to "the
///   tag did not verify."
///
/// The function is constant-time at the AEAD primitive level;
/// the framework adds no timing-leak surface on top of it.
#[allow(dead_code)] // call site lands in R3 commit #7 (verify_totp runtime)
pub(crate) fn unwrap_secret(input: &[u8], key: &MfaKey) -> Result<Vec<u8>> {
    if input.len() < 12 + 16 {
        return Err(Error::Internal(format!(
            "MFA ciphertext too short ({} bytes); minimum is 28 (nonce + tag)",
            input.len()
        )));
    }
    let (nonce_bytes, ciphertext) = input.split_at(12);
    let nonce = Nonce::from_slice(nonce_bytes);

    let cipher = Aes256Gcm::new(GcmKey::<Aes256Gcm>::from_slice(key.as_bytes()));
    cipher
        .decrypt(nonce, ciphertext)
        .map_err(|_| Error::Internal("MFA ciphertext failed AEAD verification".into()))
}

// -----------------------------------------------------------------
// Backup codes (R3 commit #4)
// -----------------------------------------------------------------

// internal:
/// Number of backup codes generated per batch. Locked at 8 per
/// `DESIGN_R3_MFA.md` Appendix B. Industry-standard range is
/// 8-16; 8 is enough for emergency use without bloating the
/// post-enrolment confirmation page.
#[allow(dead_code)] // call site lands in R3 commit #6 (enrolment runtime)
pub const BACKUP_CODE_COUNT: usize = 8;

// internal:
/// Backup-code length in characters, excluding the visual
/// hyphen separator at position 4. Locked at 8 (rendered as
/// `XXXX-XXXX`) per `DESIGN_R3_MFA.md` Appendix B.
pub(crate) const BACKUP_CODE_LEN: usize = 8;

/// 31-character ambiguity-stripped alphabet for backup codes.
/// Excludes `0` / `O` (digit zero / letter O), `1` / `I` /
/// `L` (digit one / letter I / letter L). Per
/// `DESIGN_R3_MFA.md` Appendix B locked decision; the alphabet
/// is the persistence contract — changing it breaks any code
/// that was issued under a different alphabet.
///
/// Entropy per backup code: `8 chars × log2(31) ≈ 39.6 bits`
/// — adequate for single-use rate-limited verification. The
/// design doc rounds to "≈41 bits" approximately.
const BACKUP_CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ";

/// Argon2id parameters for backup-code hashing. Locked at
/// `m = 16 MiB / t = 2 / p = 1` per `DESIGN_R3_MFA.md` §8.1.
///
/// Lower than full password Argon2id (default `m ≈ 19 MiB`)
/// because backup codes have higher entropy per character than
/// passwords and verification runs on every login attempt that
/// tries a backup code (up to `BACKUP_CODE_COUNT` rows). Full
/// Argon2id would add latency without strengthening the
/// security model meaningfully.
fn backup_code_argon2() -> Result<Argon2<'static>> {
    let params = Params::new(16 * 1024, 2, 1, None)
        .map_err(|e| Error::Internal(format!("argon2 params: {e}")))?;
    Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
}

/// Generate a fresh batch of backup codes.
///
/// Returns `count` strings of the form `XXXX-XXXX` where each
/// `X` is drawn unbiased from [`BACKUP_CODE_ALPHABET`] using
/// `rand::thread_rng().gen_range(...)`. The hyphen is purely
/// visual — at storage time the framework normalises away
/// hyphens via [`normalise_backup_code`].
///
/// **Plaintext lifecycle (D2).** The returned strings are the
/// only place the plaintext exists. Callers MUST hash via
/// [`hash_backup_code`] before persisting and MUST render the
/// plaintext to the user exactly once on the enrolment /
/// regeneration success page. After that response, the
/// plaintext is dropped from memory.
///
/// Typical caller pattern (R3 commit #6):
///
/// ```ignore
/// let codes = generate_backup_codes(BACKUP_CODE_COUNT);
/// let hashes: Vec<String> = codes
///     .iter()
///     .map(|c| hash_backup_code(c))
///     .collect::<Result<_>>()?;
/// // INSERT hashes into rustio_mfa_backup_codes
/// // RENDER `codes` to the user once, then drop
/// ```
#[allow(dead_code)] // call sites land in R3 commit #6 (enrolment) +
                    // commit when regenerate_backup_codes lands
                    // internal:
pub(crate) fn generate_backup_codes(count: usize) -> Vec<String> {
    let mut rng = rand::thread_rng();
    let alphabet_len = BACKUP_CODE_ALPHABET.len();
    (0..count)
        .map(|_| {
            // 8 chars + 1 hyphen = 9 chars total.
            let mut out = String::with_capacity(BACKUP_CODE_LEN + 1);
            for i in 0..BACKUP_CODE_LEN {
                if i == 4 {
                    out.push('-');
                }
                let idx = rng.gen_range(0..alphabet_len);
                out.push(BACKUP_CODE_ALPHABET[idx] as char);
            }
            out
        })
        .collect()
}

// internal:
/// Normalise a user-submitted backup code for hash comparison.
///
/// Strips every non-alphanumeric character (so `XXXX-XXXX`,
/// `XXXXXXXX`, `xxxx xxxx`, `xxxx-xxxx`, etc. all collapse to
/// the same canonical form) and uppercases. The hash compare
/// runs on the canonical form.
///
/// Idempotent: `normalise(normalise(x)) == normalise(x)`.
#[allow(dead_code)] // call site lands in R3 commit #8 (consume_backup_code runtime)
pub(crate) fn normalise_backup_code(input: &str) -> String {
    input
        .chars()
        .filter(|c| c.is_ascii_alphanumeric())
        .collect::<String>()
        .to_ascii_uppercase()
}

// internal:
/// Hash a backup code with Argon2id (low-memory params).
///
/// Generates a fresh 16-byte salt per call from the OS RNG.
/// Returns the PHC string (`$argon2id$v=19$m=16384,t=2,p=1$...`)
/// suitable for storage in `rustio_mfa_backup_codes.code_hash`.
/// The PHC string is self-describing — verification reads the
/// params from the hash itself.
///
/// The caller normalises the plaintext via
/// [`normalise_backup_code`] before passing to this function so
/// the user's hyphen / casing variation does not affect the
/// hash.
///
/// **Failure modes** (all `Error::Internal` — the failure is
/// operator-side at boot, not user-facing):
///
/// - Argon2id parameter construction fails (should not happen
///   with the locked `m / t / p` values).
/// - Hashing itself fails (rare; usually OOM under contrived
///   conditions).
#[allow(dead_code)] // call site lands in R3 commit #6 (enrolment runtime)
pub(crate) fn hash_backup_code(plaintext: &str) -> Result<String> {
    let argon2 = backup_code_argon2()?;
    let salt = SaltString::generate(&mut rand::thread_rng());
    let hash = argon2
        .hash_password(plaintext.as_bytes(), &salt)
        .map_err(|e| Error::Internal(format!("argon2 hash: {e}")))?;
    Ok(hash.to_string())
}

// internal:
/// Verify a normalised backup-code candidate against a stored
/// PHC hash.
///
/// Reads the Argon2 parameters from the PHC string itself, so
/// the verifier does not need to know the params used at hash
/// time. Constant-time at the Argon2id primitive level.
///
/// Returns `false` for any failure shape — invalid PHC string,
/// param mismatch, hash mismatch, etc. The caller does not
/// distinguish causes; the user-facing response is uniform per
/// `DESIGN_R3_MFA.md` §4.4.
#[allow(dead_code)] // call site lands in R3 commit #8 (consume_backup_code runtime)
pub(crate) fn verify_backup_code(plaintext: &str, hash: &str) -> bool {
    let parsed = match PasswordHash::new(hash) {
        Ok(p) => p,
        Err(_) => return false,
    };
    Argon2::default()
        .verify_password(plaintext.as_bytes(), &parsed)
        .is_ok()
}

// -----------------------------------------------------------------
// TOTP — RFC 6238 (R3 commit #5)
// -----------------------------------------------------------------
//
// Hand-rolled HMAC-SHA1-based TOTP per RFC 6238, with the
// canonical 30-second step interval and 6-digit code format.
// Pinned by the RFC 6238 Appendix B test vectors (truncated
// from 8-digit to 6-digit). The framework's TOTP secret is the
// 20-byte default; longer secrets are accepted but not
// recommended (no security gain; reduces interop surface).
//
// Why hand-rolled rather than `totp-rs`: the framework's
// dependency-conservative character (one stylesheet, narrow
// surface). RFC 6238 is small enough to review at the source
// level; the canonical test vectors give a strong correctness
// signal. See DESIGN_R3_MFA.md §9.4 + Appendix B for the
// trade-off discussion.

// internal:
/// TOTP step number for the given Unix time and step interval.
///
/// Pure function: `now_unix / step_seconds` (integer division).
/// At the canonical 30-second interval, the step value
/// increments every 30 seconds of wall-clock time. The
/// `mfa_last_used_step` column persists the highest step value
/// previously accepted by [`verify_totp`] for replay protection
/// (D4).
#[allow(dead_code)] // call sites land in R3 commit #6 (enrolment) + #7 (verify_totp)
pub fn current_step(now_unix: u64, step_seconds: u64) -> u64 {
    debug_assert!(step_seconds > 0, "step_seconds must be > 0");
    now_unix / step_seconds
}

// internal:
/// Generate a 6-digit TOTP code for the given secret + step
/// per RFC 6238 (HMAC-SHA1 + dynamic truncation per RFC 4226
/// §5.3).
///
/// Steps:
///
/// 1. Compute `hmac = HMAC-SHA1(secret, step.to_be_bytes())`.
///    The 8-byte step value is encoded big-endian per RFC 4226.
/// 2. Read `offset = hmac[19] & 0x0F`. The low nibble of the
///    last HMAC byte selects a window into the 20-byte HMAC.
/// 3. Read 4 bytes starting at `offset`, masking the high bit
///    of the first byte (drops the sign bit per RFC 4226 §5.3).
/// 4. Modulo `1_000_000` to yield a 6-digit value.
///
/// Returns the integer TOTP value in `[0, 999_999]`. Callers
/// rendering for display should pad with leading zeros via
/// `format!("{:06}", code)`.
///
/// **Infallible.** `Hmac::new_from_slice` accepts any key
/// length per the HMAC construction; the framework never
/// produces an invalid secret length internally.
#[allow(dead_code)] // call sites land in R3 commit #6 (enrolment) + #7 (verify_totp)
pub fn generate_totp(secret: &[u8], step: u64) -> u32 {
    // UFCS to disambiguate from `aes_gcm::aead::KeyInit` —
    // both traits define a `new_from_slice` method.
    let mut mac = <HmacSha1 as Mac>::new_from_slice(secret).expect("HMAC accepts any key length");
    mac.update(&step.to_be_bytes());
    let hash = mac.finalize().into_bytes();

    // Dynamic truncation per RFC 4226 §5.3.
    let offset = (hash[19] & 0x0F) as usize;
    let bin_code = u32::from_be_bytes([
        hash[offset] & 0x7F,
        hash[offset + 1],
        hash[offset + 2],
        hash[offset + 3],
    ]);

    bin_code % 1_000_000
}

// internal:
/// Verify a TOTP candidate within the configured step skew.
///
/// Tries the current step ± `skew_steps` against `candidate`.
/// Returns `Some(step)` of the matching step on success so the
/// caller can stamp `rustio_users.mfa_last_used_step` for D4
/// replay protection; returns `None` if no step in the window
/// matches.
///
/// **Replay protection runs at the call site, not here.** This
/// function reports cryptographic match only. The verify
/// runtime (R3 commit #7) reads `mfa_last_used_step` from the
/// user row and rejects matches at or below the stored value
/// before calling this function.
///
/// **Skew window** is symmetric: `[current - skew_steps,
/// current + skew_steps]` inclusive. Default skew (per
/// `RecoveryPolicy::mfa_skew_steps`) is 1, giving a 90-second
/// total acceptance window at the canonical 30-second step.
#[allow(dead_code)] // call site lands in R3 commit #7 (verify_totp runtime)
pub(crate) fn verify_totp(
    secret: &[u8],
    candidate: u32,
    now_unix: u64,
    step_seconds: u64,
    skew_steps: u32,
) -> Option<u64> {
    let current = current_step(now_unix, step_seconds);
    let skew = i64::from(skew_steps);

    for delta in -skew..=skew {
        let step_to_try = (current as i64).saturating_add(delta).max(0) as u64;
        if generate_totp(secret, step_to_try) == candidate {
            return Some(step_to_try);
        }
    }
    None
}

// -----------------------------------------------------------------
// Enrolment runtime (R3 commit #6)
// -----------------------------------------------------------------

// internal:
/// A freshly-provisioned TOTP secret + its base32 encoding for
/// QR / manual-entry display.
///
/// **Lifecycle.** The struct's two fields contain the same
/// secret in two encodings; both are plaintext. The handler
/// MUST hold this value for the duration of the GET → POST
/// enrolment hand-off (typically via in-memory session-state
/// or a short-lived encrypted form-token), then MUST discard
/// it after [`confirm_enrolment`] runs. Plaintext lives only
/// in process memory; the at-rest persistence contract (D1)
/// is enforced inside `confirm_enrolment` via [`wrap_secret`].
#[allow(dead_code)] // fields read by the enrolment GET handler in a later commit
pub struct ProvisionedSecret {
    /// 20 random bytes from the OS RNG. RFC 6238 recommends
    /// HMAC-SHA1's block size (64 bytes) or output size
    /// (20 bytes); 20 is the universal authenticator-app
    /// minimum and matches every standard QR-provisioning URL
    /// in the wild.
    pub secret_bytes: Vec<u8>,
    /// Base32 (RFC 4648) without padding — the form expected
    /// by `otpauth://totp/...?secret=<this>` URLs and by
    /// authenticator apps that accept manual entry.
    pub base32: String,
}

// internal:
/// Generate a fresh TOTP secret + its base32 encoding.
///
/// Pure (apart from the OS RNG read). Returns 20 raw bytes
/// drawn from `rand::thread_rng().fill_bytes` plus the
/// matching base32 string. Callers compose the `otpauth://`
/// URL elsewhere — this function does not touch the project's
/// issuer name or the user's email; those concerns live at the
/// HTTP layer.
#[allow(dead_code)] // call site lands in the enrolment GET handler
pub fn provision_secret() -> ProvisionedSecret {
    let mut bytes = vec![0u8; 20];
    rand::thread_rng().fill_bytes(&mut bytes);
    let base32 = base32_encode_no_pad(&bytes);
    ProvisionedSecret {
        secret_bytes: bytes,
        base32,
    }
}

// internal:
/// Build an `otpauth://totp/<issuer>:<account>?...` URL per
/// the de-facto-standard Google Authenticator Key URI format.
///
/// Authenticator apps consume this URL (typically via a QR
/// code) to provision the secret + verify-side params in one
/// step. The framework emits the URL; the enrolment template
/// renders it as a clickable link and a manual-entry fallback.
///
/// Format:
///
/// ```text
/// otpauth://totp/<issuer>:<account>?secret=<base32>
///                                 &issuer=<issuer>
///                                 &algorithm=SHA1
///                                 &digits=6
///                                 &period=<step_seconds>
/// ```
///
/// Both `<issuer>` (in the path) and the `&issuer=` query
/// param are populated — older authenticator apps parse one
/// but not the other; including both is the broadest-compat
/// move per Google's own spec.
#[allow(dead_code)] // call site lands at the enrolment GET handler (R3 commit #13)
pub(crate) fn build_otpauth_url(
    issuer: &str,
    account: &str,
    base32_secret: &str,
    step_seconds: u64,
) -> String {
    let issuer_enc = urlencoding::encode(issuer);
    let account_enc = urlencoding::encode(account);
    format!(
        "otpauth://totp/{issuer_enc}:{account_enc}?secret={base32_secret}\
         &issuer={issuer_enc}&algorithm=SHA1&digits=6&period={step_seconds}"
    )
}

/// RFC 4648 base32 encoder (no padding). Hand-rolled rather
/// than added as a dependency to match the framework's
/// dependency-conservative character — base32 is ~30 lines and
/// the alphabet is the persistence contract for the
/// `otpauth://...?secret=...` URL format.
///
/// Pinned by the standard RFC 4648 §10 test vector
/// `"foobar" -> "MZXW6YTBOI"`.
fn base32_encode_no_pad(bytes: &[u8]) -> String {
    const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
    let mut out = String::with_capacity(bytes.len().div_ceil(5) * 8);
    let mut buffer: u32 = 0;
    let mut bits_in_buffer: u8 = 0;
    for &byte in bytes {
        buffer = (buffer << 8) | u32::from(byte);
        bits_in_buffer += 8;
        while bits_in_buffer >= 5 {
            bits_in_buffer -= 5;
            let idx = (buffer >> bits_in_buffer) as usize & 0x1F;
            out.push(ALPHA[idx] as char);
        }
    }
    if bits_in_buffer > 0 {
        let idx = (buffer << (5 - bits_in_buffer)) as usize & 0x1F;
        out.push(ALPHA[idx] as char);
    }
    out
}

// internal:
/// RFC 4648 base32 decoder (no padding), used to recover the
/// TOTP secret from the enrolment form's hidden `secret_base32`
/// field. Inverse of [`base32_encode_no_pad`].
///
/// Accepts:
/// - The 32-character base32 alphabet (A-Z, 2-7), case
///   insensitive.
/// - Whitespace, hyphens, and `=` padding chars are silently
///   stripped before decode (matches what users typically paste
///   from authenticator apps).
///
/// Returns `None` if any non-alphabet character survives the
/// strip — including the four ambiguity-rejected base32 letters
/// (0, 1, 8, 9). The handler maps `None` to a uniform "invalid
/// code" outcome.
///
/// Pinned by round-trip tests:
/// `decode(encode(input)) == input` for arbitrary input.
#[allow(dead_code)] // call site lands at the enrolment POST handler (R3 commit #13)
pub(crate) fn base32_decode_no_pad(input: &str) -> Option<Vec<u8>> {
    let mut buffer: u32 = 0;
    let mut bits_in_buffer: u8 = 0;
    let mut out = Vec::with_capacity(input.len() * 5 / 8 + 1);
    for c in input.chars() {
        // Tolerate hyphens / spaces / `=` padding so paste-able
        // strings work without further normalisation at the
        // call site.
        if c.is_ascii_whitespace() || c == '-' || c == '=' {
            continue;
        }
        let value: u32 = match c.to_ascii_uppercase() {
            'A'..='Z' => (c.to_ascii_uppercase() as u32) - ('A' as u32),
            '2'..='7' => (c as u32) - ('2' as u32) + 26,
            _ => return None,
        };
        buffer = (buffer << 5) | value;
        bits_in_buffer += 5;
        if bits_in_buffer >= 8 {
            bits_in_buffer -= 8;
            out.push(((buffer >> bits_in_buffer) & 0xFF) as u8);
        }
    }
    // Leftover < 5 bits are zero-padding from the encoder's
    // tail flush. Accept them silently; rejecting non-zero
    // leftover bits is over-strict for the otpauth use case.
    Some(out)
}

// internal:
/// Outcome of [`confirm_enrolment`]. Lets the caller render the
/// right page without embedding HTTP concerns in the runtime
/// layer.
#[allow(dead_code)] // variants light up at the HTTP handler in a later commit
pub enum EnrolOutcome {
    /// The user's first TOTP code matched the just-provisioned
    /// secret. The secret has been encrypted and persisted on
    /// the user row; the 8 backup codes have been hashed and
    /// inserted into `rustio_mfa_backup_codes`. The plaintext
    /// backup codes ride in the variant for the one-time
    /// success-page render (D2).
    Enrolled { plain_backup_codes: Vec<String> },
    /// The candidate code did not match the secret within the
    /// configured skew window. No DB writes occurred; the
    /// caller can re-render the verify form.
    InvalidCode,
    /// The user already has `mfa_enabled = TRUE`. Defensive
    /// — should not happen if the enrolment handler checks
    /// state up-front, but the runtime refuses anyway to keep
    /// the contract honest.
    AlreadyEnrolled,
}

// internal:
/// Confirm a TOTP enrolment by verifying the user's first code
/// against the provisioned secret, then persisting everything.
///
/// **Inputs.**
///
/// - `request` — for client-IP capture into the audit row.
/// - `user_id` — the enrolling user (self-action; actor == target).
/// - `secret_bytes` — the 20-byte TOTP secret returned by
///   [`provision_secret`]. The handler holds this across the
///   GET → POST round-trip and passes it back here.
/// - `candidate_code` — the 6-digit TOTP code the user typed.
/// - `step_seconds` — TOTP step interval (locked at 30s per
///   Appendix B).
/// - `skew_steps` — accepted skew window (locked at ±1 per
///   Appendix B).
/// - `key` — the AES-256-GCM key for at-rest encryption.
/// - `key_id` — the active `RUSTIO_SECRET_KEY` version, stamped
///   onto `mfa_secret_key_id` for staged-rotation decryption (D8).
/// - `correlation_id` — forensic-chain anchor.
///
/// **Steps.**
///
/// 1. SELECT `mfa_enabled`. If TRUE → `AlreadyEnrolled` (no DB
///    writes).
/// 2. `verify_totp`. If no step matches → `InvalidCode` (no DB
///    writes).
/// 3. AES-256-GCM encrypt the secret via [`wrap_secret`].
/// 4. UPDATE `rustio_users` setting `mfa_enabled = TRUE`,
///    `mfa_secret_ciphertext`, `mfa_secret_key_id`, and
///    `mfa_last_used_step` (the step that just verified, for D4
///    replay protection).
/// 5. [`generate_backup_codes`] (`BACKUP_CODE_COUNT`).
/// 6. Hash each via [`hash_backup_code`] and INSERT into
///    `rustio_mfa_backup_codes`.
/// 7. Emit `AuditEvent::MfaEnabled` with metadata
///    `{ "backup_codes_count", "key_id" }`.
///
/// Returns `EnrolOutcome::Enrolled { plain_backup_codes }`. The
/// caller renders the codes ONCE on the success page, then
/// drops the strings. After the response is sent, the only
/// place the codes exist is the Argon2id hashes in the DB.
///
/// **Doctrine 22.** This function does not write `revoked_at`.
/// The audit emission and DB updates do not pass through
/// `invalidate_sessions`; enrolment does not invalidate
/// existing sessions per `DESIGN_R3_MFA.md` §4.1.
#[allow(dead_code)] // call site lands at the enrolment POST handler in a later commit
#[allow(clippy::too_many_arguments)]
pub async fn confirm_enrolment(
    db: &Db,
    request: &Request,
    user_id: i64,
    secret_bytes: &[u8],
    candidate_code: u32,
    step_seconds: u64,
    skew_steps: u32,
    key: &MfaKey,
    key_id: u32,
    correlation_id: Option<&str>,
) -> Result<EnrolOutcome> {
    // 1. Refuse if already enrolled.
    let already: Option<bool> =
        sqlx::query_scalar("SELECT mfa_enabled FROM rustio_users WHERE id = $1")
            .bind(user_id)
            .fetch_optional(db.pool())
            .await?;
    let Some(already) = already else {
        return Err(Error::NotFound(format!("user {user_id} not found")));
    };
    if already {
        return Ok(EnrolOutcome::AlreadyEnrolled);
    }

    // 2. Verify the candidate code against the freshly-provisioned
    //    secret.
    let now_unix = Utc::now().timestamp().max(0) as u64;
    let step = match verify_totp(
        secret_bytes,
        candidate_code,
        now_unix,
        step_seconds,
        skew_steps,
    ) {
        Some(step) => step,
        None => return Ok(EnrolOutcome::InvalidCode),
    };

    // 3. Encrypt the secret for at-rest storage (D1).
    let ciphertext = wrap_secret(secret_bytes, key);

    // 4. Update the user row. Stamps mfa_last_used_step with the
    //    step that just verified, so the very first verify_totp
    //    after enrolment cannot replay this same code (D4).
    sqlx::query(
        "UPDATE rustio_users \
         SET mfa_enabled = TRUE, \
             mfa_secret_ciphertext = $1, \
             mfa_secret_key_id = $2, \
             mfa_last_used_step = $3 \
         WHERE id = $4",
    )
    .bind(&ciphertext)
    .bind(key_id as i32)
    .bind(step as i64)
    .bind(user_id)
    .execute(db.pool())
    .await?;

    // 5. Generate the backup-code batch.
    let plain_codes = generate_backup_codes(BACKUP_CODE_COUNT);

    // 6. Hash + insert each. Normalisation runs at consume time
    //    (the user types XXXX-XXXX with the hyphen); the hashes
    //    persist the canonical form.
    for code in &plain_codes {
        let normalised = normalise_backup_code(code);
        let hash = hash_backup_code(&normalised)?;
        sqlx::query("INSERT INTO rustio_mfa_backup_codes (user_id, code_hash) VALUES ($1, $2)")
            .bind(user_id)
            .bind(&hash)
            .execute(db.pool())
            .await?;
    }

    // 7. Audit emit.
    let metadata = serde_json::json!({
        "backup_codes_count": BACKUP_CODE_COUNT,
        "key_id": key_id,
    });
    let ip = client_ip(request);
    let mut entry = LogEntry::new(user_id, ActionType::Update, "users", user_id)
        .with_event(AuditEvent::MfaEnabled)
        .with_actor(user_id);
    entry.correlation_id = correlation_id;
    entry.ip_address = ip.as_deref();
    entry.metadata = Some(metadata);
    entry.summary = "MFA enabled (TOTP + 8 backup codes)".to_string();
    audit_record(db, entry).await?;

    Ok(EnrolOutcome::Enrolled {
        plain_backup_codes: plain_codes,
    })
}

// -----------------------------------------------------------------
// Verification runtime — TOTP login second factor (R3 commit #7)
// -----------------------------------------------------------------

// internal:
/// Outcome of [`verify_totp_for_user`]. Lets the verify
/// handler render the right page without embedding HTTP
/// concerns in the runtime layer.
///
/// All four variants collapse to a uniform user-facing
/// response per `DESIGN_R3_MFA.md` §3.1 disclosure rules —
/// the handler must NOT render different copy for `Replay`
/// vs `Invalid` vs `NotEnrolled`. The variant distinctions
/// exist for forensic logging, future audit emission, and
/// internal debugging only.
#[allow(dead_code)] // variants light up at the verify handler in a later commit
pub enum VerifyOutcome {
    /// The candidate code matched within the skew window AND
    /// the matched step is strictly greater than
    /// `mfa_last_used_step`. The runtime has stamped the new
    /// step; the caller proceeds with trust escalation
    /// (mint a fresh `mfa_verified` session row, revoke the
    /// pending row, swap the cookie).
    Verified { step_used: u64 },
    /// The candidate matched cryptographically but the matched
    /// step is at or below `mfa_last_used_step` — D4 replay
    /// protection. Cause: a network-captured code, a
    /// double-submit, or clock drift on the user's device.
    /// Caller MUST NOT trust-escalate; user-facing copy is
    /// uniform with `Invalid`.
    Replay { last_used_step: u64 },
    /// The candidate did not match within the configured skew
    /// window, or the candidate string was not parseable as a
    /// 6-digit number.
    Invalid,
    /// The user row exists but `mfa_enabled = FALSE`. Should
    /// not happen if the verify handler checks state up-front,
    /// but the runtime refuses anyway to keep the contract
    /// honest.
    NotEnrolled,
}

// internal:
/// Verify a TOTP candidate for an enrolled user.
///
/// **Inputs.**
///
/// - `user_id` — the user being challenged.
/// - `candidate_code_str` — the 6-digit string the user typed.
///   Whitespace-trimmed and parsed to `u32`; invalid input
///   collapses to `VerifyOutcome::Invalid`.
/// - `step_seconds` — TOTP step interval (locked at 30s per
///   Appendix B).
/// - `skew_steps` — accepted skew window (locked at ±1 per
///   Appendix B).
/// - `key` — the AES-256-GCM key for at-rest decryption.
///   Future: when the `MfaSecretKeyResolver` trait lands,
///   this becomes a resolver lookup keyed by the row's
///   `mfa_secret_key_id`. For now (R3 commit #7) the
///   framework assumes a single active key (`key_id = 1`).
///
/// **Steps.**
///
/// 1. Parse `candidate_code_str` as a 6-digit `u32`. Failure
///    → `Invalid`.
/// 2. SELECT `mfa_enabled`, `mfa_secret_ciphertext`,
///    `mfa_last_used_step` from `rustio_users`. Missing row
///    → `Error::NotFound`.
/// 3. If `!mfa_enabled` → `NotEnrolled`.
/// 4. If ciphertext is `NULL` while `mfa_enabled = TRUE` →
///    `Error::Internal` (corrupted state — cannot happen
///    via the framework's own writes).
/// 5. [`unwrap_secret`] decrypts the ciphertext under `key`.
///    Decryption failure surfaces as `Error::Internal` (key
///    mismatch or tampering — operator-side recovery).
/// 6. [`verify_totp`] against the secret. No match within the
///    skew window → `Invalid`.
/// 7. **D4 replay check.** If the matched step is at or below
///    `mfa_last_used_step` → `Replay`. The previously-stored
///    step value rides in the variant for forensic logging.
/// 8. UPDATE `mfa_last_used_step` to the just-verified step.
/// 9. Return `Verified { step_used }`.
///
/// **No audit row.** TOTP success is captured via the
/// session-promotion `parent_session_id` lineage per §8.3.
/// Backup-code consume DOES emit `AuditEvent::MfaCodeConsumed`
/// (R3 commit #8).
///
/// **Doctrine 22.** This function does not write `revoked_at`.
/// The trust-escalation that follows (mint fresh
/// `mfa_verified` row + revoke pending row + swap cookie)
/// runs through `auth::sessions::invalidate_sessions` at the
/// handler level — not here.
#[allow(dead_code)] // call site lands at the verify POST handler in a later commit
pub async fn verify_totp_for_user(
    db: &Db,
    user_id: i64,
    candidate_code_str: &str,
    step_seconds: u64,
    skew_steps: u32,
    key: &MfaKey,
) -> Result<VerifyOutcome> {
    use sqlx::Row as _;

    // 1. Parse the candidate as a 6-digit u32.
    let candidate = match candidate_code_str.trim().parse::<u32>() {
        Ok(n) if n < 1_000_000 => n,
        _ => return Ok(VerifyOutcome::Invalid),
    };

    // 2. Read MFA state from the user row.
    let row = sqlx::query(
        "SELECT mfa_enabled, mfa_secret_ciphertext, mfa_last_used_step \
         FROM rustio_users WHERE id = $1",
    )
    .bind(user_id)
    .fetch_optional(db.pool())
    .await?;
    let row = row.ok_or_else(|| Error::NotFound(format!("user {user_id} not found")))?;

    let mfa_enabled: bool = row.try_get("mfa_enabled")?;
    if !mfa_enabled {
        return Ok(VerifyOutcome::NotEnrolled);
    }

    let ciphertext: Option<Vec<u8>> = row.try_get("mfa_secret_ciphertext")?;
    let last_used_step: Option<i64> = row.try_get("mfa_last_used_step")?;

    let ciphertext = ciphertext.ok_or_else(|| {
        Error::Internal(format!(
            "user {user_id} has mfa_enabled=TRUE but mfa_secret_ciphertext IS NULL"
        ))
    })?;

    // 3. Decrypt the secret. Failure here is operator-side:
    //    wrong key (rotation issue) or tampered ciphertext (DB
    //    attack). Both surface as Error::Internal; the user
    //    sees a uniform error response from the handler.
    let secret_bytes = unwrap_secret(&ciphertext, key)?;

    // 4. Verify the candidate against the secret + skew window.
    let now_unix = Utc::now().timestamp().max(0) as u64;
    let step = match verify_totp(&secret_bytes, candidate, now_unix, step_seconds, skew_steps) {
        Some(step) => step,
        None => return Ok(VerifyOutcome::Invalid),
    };

    // 5. D4 replay protection. mfa_last_used_step is monotonic
    //    per user; a TOTP code from a step ≤ the stored value
    //    is rejected even if it just verified cryptographically.
    //    A NULL last-used-step (theoretically unreachable after
    //    confirm_enrolment stamps it; included defensively) is
    //    treated as -1 so any non-negative step value passes
    //    the comparison.
    let last = last_used_step.unwrap_or(-1);
    if (step as i64) <= last {
        return Ok(VerifyOutcome::Replay {
            last_used_step: last.max(0) as u64,
        });
    }

    // 6. Stamp the new step. Subsequent verifications with the
    //    same step value (replay) will be rejected at step 5
    //    above.
    sqlx::query("UPDATE rustio_users SET mfa_last_used_step = $1 WHERE id = $2")
        .bind(step as i64)
        .bind(user_id)
        .execute(db.pool())
        .await?;

    Ok(VerifyOutcome::Verified { step_used: step })
}

// -----------------------------------------------------------------
// Backup-code consume runtime (R3 commit #8)
// -----------------------------------------------------------------

// internal:
/// Outcome of [`consume_backup_code`]. Lets the verify handler
/// render the right page without embedding HTTP concerns in the
/// runtime layer.
///
/// All variants collapse to a uniform user-facing response per
/// `DESIGN_R3_MFA.md` §3.1 disclosure rules — the handler MUST
/// NOT distinguish `Invalid` from `AlreadyUsed` from
/// `NotEnrolled` in the rendered copy. The variant distinctions
/// exist for forensic logging and internal debugging only.
#[allow(dead_code)] // variants light up at the verify handler in a later commit
pub enum BackupConsumeOutcome {
    /// The candidate matched an unused backup code. The row has
    /// been atomically marked `used_at = NOW()`; the audit row
    /// has been emitted. The caller proceeds with trust
    /// escalation (mint fresh `mfa_verified` session row, revoke
    /// the pending row, swap the cookie).
    Consumed { code_id: i64, remaining: u32 },
    /// The candidate did not match any unused row, OR the input
    /// failed normalisation, OR a race against a parallel
    /// consume request lost. Uniform copy with `AlreadyUsed`
    /// per the disclosure rule.
    Invalid,
    /// The user row exists but `mfa_enabled = FALSE`. Should
    /// not happen if the verify handler checks state up-front,
    /// but the runtime refuses anyway to keep the contract
    /// honest.
    NotEnrolled,
    /// Reserved for the case where the SELECT widens beyond
    /// `WHERE used_at IS NULL`. The current SELECT filters at
    /// the index level so this variant is unreachable; it is
    /// retained for forward-compatibility per
    /// `DESIGN_R3_MFA.md` §9.2.
    #[allow(dead_code)]
    AlreadyUsed,
}

// internal:
/// Consume a backup code as the second factor on the verify
/// flow.
///
/// **Inputs.**
///
/// - `request` — for client-IP capture into the audit row.
/// - `user_id` — the user being challenged.
/// - `candidate_str` — the raw input the user typed. Hyphen
///   and casing are normalised via
///   [`normalise_backup_code`] before hash compare.
/// - `via` — caller context (`"login"` or `"reauth"`)
///   recorded into `metadata.via` per §8.2.
/// - `correlation_id` — forensic-chain anchor.
///
/// **Steps.**
///
/// 1. Normalise the candidate. Empty after normalisation →
///    `Invalid`.
/// 2. SELECT `mfa_enabled` from `rustio_users`. Missing row →
///    `Error::NotFound`. `mfa_enabled = FALSE` → `NotEnrolled`.
/// 3. SELECT `id, code_hash` from `rustio_mfa_backup_codes`
///    WHERE `user_id = ? AND used_at IS NULL`. The partial
///    index makes this an index seek scoped to ≤
///    `BACKUP_CODE_COUNT` rows.
/// 4. **Constant-time iteration** over the rows. Argon2id
///    verify each candidate; do NOT break on first match. The
///    matched id is recorded once; subsequent matches (cannot
///    happen given fresh-salt-per-row) are ignored. Iterating
///    every row prevents a timing leak about the matching
///    index.
/// 5. No match → `Invalid`.
/// 6. **Atomic single-use UPDATE.** `UPDATE … SET used_at =
///    NOW() WHERE id = $1 AND used_at IS NULL`. If
///    `rows_affected = 0`, another concurrent request consumed
///    the same code first; treated as `Invalid` for uniform
///    user-facing response (D7 protected at the SQL level).
/// 7. Count remaining unused codes for the audit metadata +
///    caller's render decision (the handler may flash a
///    "regenerate" warning when `remaining ≤ 2`).
/// 8. Emit `AuditEvent::MfaCodeConsumed` with metadata
///    `{ code_id, remaining_codes, via }`.
///
/// **Doctrine 22.** This function does not write `revoked_at`.
/// The trust escalation that follows on `Consumed` runs
/// through `auth::sessions::invalidate_sessions` at the
/// handler level — not here.
///
/// **Audit row emits inside the function.** Unlike
/// [`verify_totp_for_user`] which is silent (TOTP success is
/// captured via session-promotion lineage), backup-code
/// consume is an out-of-band recovery event worth surfacing in
/// the forensic chain.
#[allow(dead_code)] // call site lands at the verify POST handler in a later commit
pub async fn consume_backup_code(
    db: &Db,
    request: &Request,
    user_id: i64,
    candidate_str: &str,
    via: &'static str,
    correlation_id: Option<&str>,
) -> Result<BackupConsumeOutcome> {
    use sqlx::Row as _;

    // 1. Normalise.
    let candidate = normalise_backup_code(candidate_str);
    if candidate.is_empty() {
        return Ok(BackupConsumeOutcome::Invalid);
    }

    // 2. Verify enrolment.
    let mfa_enabled: Option<bool> =
        sqlx::query_scalar("SELECT mfa_enabled FROM rustio_users WHERE id = $1")
            .bind(user_id)
            .fetch_optional(db.pool())
            .await?;
    let mfa_enabled =
        mfa_enabled.ok_or_else(|| Error::NotFound(format!("user {user_id} not found")))?;
    if !mfa_enabled {
        return Ok(BackupConsumeOutcome::NotEnrolled);
    }

    // 3. SELECT all unused candidates.
    let rows = sqlx::query(
        "SELECT id, code_hash FROM rustio_mfa_backup_codes \
         WHERE user_id = $1 AND used_at IS NULL \
         ORDER BY id",
    )
    .bind(user_id)
    .fetch_all(db.pool())
    .await?;

    // 4. Constant-time iteration. Verify against every row even
    //    after a match; record the first matched id only. Per
    //    §4.4: do not break on first match — leaks timing about
    //    candidate ordering otherwise.
    let mut matched_id: Option<i64> = None;
    for row in &rows {
        let id: i64 = row.try_get("id")?;
        let hash: String = row.try_get("code_hash")?;
        if verify_backup_code(&candidate, &hash) && matched_id.is_none() {
            matched_id = Some(id);
        }
    }

    let Some(matched_id) = matched_id else {
        return Ok(BackupConsumeOutcome::Invalid);
    };

    // 5. Atomic single-use UPDATE. The `AND used_at IS NULL`
    //    clause guards against a parallel consume of the same
    //    code: only one of two concurrent requests sees
    //    rows_affected = 1; the loser collapses to Invalid for
    //    uniform user-facing response.
    let result = sqlx::query(
        "UPDATE rustio_mfa_backup_codes \
         SET used_at = NOW() \
         WHERE id = $1 AND used_at IS NULL",
    )
    .bind(matched_id)
    .execute(db.pool())
    .await?;

    if result.rows_affected() == 0 {
        return Ok(BackupConsumeOutcome::Invalid);
    }

    // 6. Count remaining unused codes for the metadata.
    let remaining: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM rustio_mfa_backup_codes \
         WHERE user_id = $1 AND used_at IS NULL",
    )
    .bind(user_id)
    .fetch_one(db.pool())
    .await?;
    let remaining = remaining.max(0) as u32;

    // 7. Emit AuditEvent::MfaCodeConsumed.
    let metadata = serde_json::json!({
        "code_id": matched_id,
        "remaining_codes": remaining,
        "via": via,
    });
    let ip = client_ip(request);
    let mut entry = LogEntry::new(user_id, ActionType::Update, "users", user_id)
        .with_event(AuditEvent::MfaCodeConsumed)
        .with_actor(user_id);
    entry.correlation_id = correlation_id;
    entry.ip_address = ip.as_deref();
    entry.metadata = Some(metadata);
    entry.summary = format!("backup code consumed via {via}; {remaining} remaining");
    audit_record(db, entry).await?;

    Ok(BackupConsumeOutcome::Consumed {
        code_id: matched_id,
        remaining,
    })
}

// -----------------------------------------------------------------
// Disable MFA runtime (R3 commit #9)
// -----------------------------------------------------------------

// internal:
/// Outcome of [`disable_mfa`]. Lets the disable handler render
/// the right page without embedding HTTP concerns in the
/// runtime layer.
#[allow(dead_code)] // variants light up at the disable handler in a later commit
pub enum DisableOutcome {
    /// MFA disabled successfully. The user row's four MFA
    /// columns are reset (`mfa_enabled = FALSE`, the secret +
    /// key id + last-used step all NULL). The backup-code rows
    /// are deleted. All sessions for the user are revoked with
    /// `SessionInvalidationReason::MfaDisabled`. The
    /// `MfaDisabled` audit row is emitted.
    Disabled { sessions_revoked: usize },
    /// The user row exists but `mfa_enabled = FALSE`. No
    /// writes; defensive against accidental double-disable.
    NotEnrolled,
    /// Reserved for the case where the framework's active
    /// `MfaPolicy` requires MFA for this user's role and the
    /// runtime is called with policy enforcement enabled. The
    /// current runtime does NOT consult the policy — the
    /// handler is responsible for refusing self-disable under
    /// `MfaPolicy::Required` BEFORE invoking this function. The
    /// variant is retained per `DESIGN_R3_MFA.md` §9.2 for
    /// forward-compat when a future commit pushes policy
    /// enforcement into the runtime layer.
    #[allow(dead_code)]
    PolicyRequired,
}

// internal:
/// Disable MFA for a user.
///
/// **Inputs.**
///
/// - `request` — for client-IP capture into the audit row.
/// - `user_id` — the user disabling their own MFA (self-action).
///   Admin-driven disable (`MfaDisabledByOther` reason) ships
///   in R4 CLI emergency recovery per `DESIGN_R3_MFA.md` §1.2;
///   this runtime handles the self-disable path only.
/// - `correlation_id` — forensic-chain anchor.
///
/// **Steps.**
///
/// 1. SELECT `mfa_enabled`. Missing row → `Error::NotFound`.
///    `mfa_enabled = FALSE` → `NotEnrolled` (no writes).
/// 2. SELECT COUNT(*) of existing backup-code rows for the
///    `previous_backup_codes_count` audit metadata.
/// 3. UPDATE `rustio_users` clearing all four MFA columns
///    atomically: `mfa_enabled = FALSE`, ciphertext / key_id /
///    last_used_step → NULL.
/// 4. DELETE all backup-code rows for the user. The user-row
///    UPDATE alone would leave orphan rows that the
///    `ON DELETE CASCADE` clause does not cover (the user row
///    survives the disable; only the backup-code rows
///    disappear).
/// 5. `invalidate_sessions(SessionTarget::User { user_id },
///    SessionInvalidationReason::MfaDisabled)` — Doctrine 22's
///    sole writer of `revoked_at`. Every session for this user
///    revokes; the current device included. After disable,
///    the user signs back in with password only.
/// 6. Emit `AuditEvent::MfaDisabled` with metadata
///    `{ reason: "self_disabled", previous_backup_codes_count }`
///    per §8.2.
///
/// **Doctrine 22.** This function delegates revocation to
/// `auth::sessions::invalidate_sessions` — does NOT write
/// `revoked_at` directly. The single-writer invariant
/// survives. The grep proof remains intact.
///
/// **Audit emits AFTER invalidation succeeds.** Audit captures
/// what actually happened; a partial success that fails
/// invalidation never produces an audit row.
#[allow(dead_code)] // call site lands at the disable POST handler in a later commit
pub async fn disable_mfa(
    db: &Db,
    request: &Request,
    user_id: i64,
    correlation_id: Option<&str>,
) -> Result<DisableOutcome> {
    // 1. Confirm enrolment.
    let mfa_enabled: Option<bool> =
        sqlx::query_scalar("SELECT mfa_enabled FROM rustio_users WHERE id = $1")
            .bind(user_id)
            .fetch_optional(db.pool())
            .await?;
    let mfa_enabled =
        mfa_enabled.ok_or_else(|| Error::NotFound(format!("user {user_id} not found")))?;
    if !mfa_enabled {
        return Ok(DisableOutcome::NotEnrolled);
    }

    // 2. Count existing backup-code rows for the audit row's
    //    metadata.previous_backup_codes_count.
    let previous_count: i64 =
        sqlx::query_scalar("SELECT COUNT(*) FROM rustio_mfa_backup_codes WHERE user_id = $1")
            .bind(user_id)
            .fetch_one(db.pool())
            .await?;
    let previous_count = previous_count.max(0) as u32;

    // 3. Clear all four MFA columns on the user row in one
    //    UPDATE. Atomicity is per-row at the SQL level — readers
    //    of rustio_users will never observe a half-disabled
    //    state (e.g. mfa_enabled = FALSE while
    //    mfa_secret_ciphertext still contains ciphertext).
    sqlx::query(
        "UPDATE rustio_users \
         SET mfa_enabled = FALSE, \
             mfa_secret_ciphertext = NULL, \
             mfa_secret_key_id = NULL, \
             mfa_last_used_step = NULL \
         WHERE id = $1",
    )
    .bind(user_id)
    .execute(db.pool())
    .await?;

    // 4. Delete backup-code rows. The schema's ON DELETE
    //    CASCADE handles user-row deletion; this DELETE handles
    //    disable-without-user-deletion (the common case).
    sqlx::query("DELETE FROM rustio_mfa_backup_codes WHERE user_id = $1")
        .bind(user_id)
        .execute(db.pool())
        .await?;

    // 5. Revoke every session via the centralised single writer
    //    of revoked_at (Doctrine 22).
    let invalidation = invalidate_sessions(
        db,
        SessionTarget::User { user_id },
        SessionInvalidationReason::MfaDisabled,
    )
    .await?;
    let sessions_revoked = invalidation.revoked_session_ids.len();

    // 6. Audit emit AFTER all DB writes succeed (D8).
    let metadata = serde_json::json!({
        "reason": "self_disabled",
        "previous_backup_codes_count": previous_count,
        "sessions_revoked": sessions_revoked,
    });
    let ip = client_ip(request);
    let mut entry = LogEntry::new(user_id, ActionType::Update, "users", user_id)
        .with_event(AuditEvent::MfaDisabled)
        .with_actor(user_id);
    entry.correlation_id = correlation_id;
    entry.ip_address = ip.as_deref();
    entry.metadata = Some(metadata);
    entry.summary = format!(
        "MFA self-disabled; {previous_count} backup codes deleted; \
         {sessions_revoked} sessions revoked"
    );
    audit_record(db, entry).await?;

    Ok(DisableOutcome::Disabled { sessions_revoked })
}

// -----------------------------------------------------------------
// Backup-code regenerate runtime (R3 commit #10)
// -----------------------------------------------------------------

// internal:
/// Outcome of [`regenerate_backup_codes`]. Lets the regenerate
/// handler render the right page without embedding HTTP
/// concerns in the runtime layer.
#[allow(dead_code)] // variants light up at the regenerate handler in a later commit
pub enum RegenOutcome {
    /// A fresh batch of `BACKUP_CODE_COUNT` codes was generated
    /// inside an atomic transaction (D3). The old batch — all
    /// rows for this user — was deleted in the same transaction
    /// and is unrecoverable from the moment the commit landed.
    /// `previous_codes_invalidated` is the count of rows the
    /// DELETE removed (used + unused combined).
    /// `plain_backup_codes` carries the freshly-generated
    /// plaintext for the one-time success-page render (D2);
    /// the caller MUST drop them after the response.
    Regenerated {
        plain_backup_codes: Vec<String>,
        previous_codes_invalidated: u32,
    },
    /// The user row exists but `mfa_enabled = FALSE`. No
    /// writes; regenerating codes for a non-enrolled user is
    /// a no-op refused by the runtime.
    NotEnrolled,
}

// internal:
/// Regenerate the backup-code batch for a user atomically.
///
/// **Inputs.**
///
/// - `request` — for client-IP capture into the audit row.
/// - `user_id` — the user regenerating their own batch
///   (self-action; re-auth gating is the handler's concern).
/// - `correlation_id` — forensic-chain anchor.
///
/// **Steps.**
///
/// 1. Generate `BACKUP_CODE_COUNT` plaintext codes via
///    [`generate_backup_codes`] and hash each via
///    [`hash_backup_code`] (Argon2id, low-memory params). The
///    Argon2id hashing is slow; runs OUTSIDE the transaction
///    so the row lock is held only for the brief DELETE +
///    INSERT window.
/// 2. BEGIN TRANSACTION.
/// 3. `SELECT mfa_enabled FROM rustio_users WHERE id = $1 FOR UPDATE`.
///    The `FOR UPDATE` row lock serialises concurrent regenerate
///    calls for the same user — without it, two simultaneous
///    requests would each DELETE then INSERT, leaving 16
///    active codes (the union of both batches). Missing row →
///    `Error::NotFound` (rolled back). `mfa_enabled = FALSE` →
///    `NotEnrolled` (rolled back; no writes).
/// 4. `SELECT COUNT(*)` of existing rows for
///    `metadata.previous_codes_invalidated`. Includes used +
///    unused since the DELETE removes both.
/// 5. `DELETE FROM rustio_mfa_backup_codes WHERE user_id = ?`.
///    Wipes the old batch.
/// 6. INSERT each freshly-hashed code.
/// 7. COMMIT.
/// 8. Emit `AuditEvent::BackupCodesRegenerated` with metadata
///    `{ previous_codes_invalidated, new_codes_count }` per §8.2.
/// 9. Return `Regenerated { plain_backup_codes,
///    previous_codes_invalidated }`. The caller renders the
///    plaintext codes ONCE on the success page, then drops
///    them.
///
/// **Doctrine 22.** This function does not write `revoked_at`.
/// Regeneration does not invalidate sessions per §4.5 — the
/// user's existing mfa_verified sessions remain valid; only
/// the backup-code rows are replaced.
///
/// **D3 atomicity proof.** The DELETE + INSERTs run inside a
/// single sqlx transaction. From the moment `tx.commit()`
/// returns, the only backup codes for this user are the new
/// 8; the old batch's hashes are gone from the database. A
/// crash between DELETE and COMMIT rolls back via Postgres's
/// MVCC — both states (old batch intact / new batch active)
/// are observable; no in-between is.
#[allow(dead_code)] // call site lands at the regenerate POST handler in a later commit
pub async fn regenerate_backup_codes(
    db: &Db,
    request: &Request,
    user_id: i64,
    correlation_id: Option<&str>,
) -> Result<RegenOutcome> {
    // 1. Generate + hash OUTSIDE the transaction. Argon2id
    //    hashing is the slowest step; holding a row lock
    //    across it would pessimistically block other reads of
    //    rustio_users for this user.
    let plain_codes = generate_backup_codes(BACKUP_CODE_COUNT);
    let hashes: Vec<String> = plain_codes
        .iter()
        .map(|c| {
            let normalised = normalise_backup_code(c);
            hash_backup_code(&normalised)
        })
        .collect::<Result<Vec<String>>>()?;

    // 2-7. Atomic transaction.
    let mut tx = db.pool().begin().await?;

    // 3. SELECT … FOR UPDATE — serialises concurrent regenerates.
    let mfa_enabled: Option<bool> =
        sqlx::query_scalar("SELECT mfa_enabled FROM rustio_users WHERE id = $1 FOR UPDATE")
            .bind(user_id)
            .fetch_optional(&mut *tx)
            .await?;
    let mfa_enabled =
        mfa_enabled.ok_or_else(|| Error::NotFound(format!("user {user_id} not found")))?;
    if !mfa_enabled {
        // tx auto-rollbacks on drop.
        return Ok(RegenOutcome::NotEnrolled);
    }

    // 4. Count the about-to-be-invalidated rows for the audit
    //    metadata. SELECT runs against the same snapshot as
    //    the DELETE below since we are inside the same tx.
    let previous_count: i64 =
        sqlx::query_scalar("SELECT COUNT(*) FROM rustio_mfa_backup_codes WHERE user_id = $1")
            .bind(user_id)
            .fetch_one(&mut *tx)
            .await?;
    let previous_count = previous_count.max(0) as u32;

    // 5. Wipe the old batch.
    sqlx::query("DELETE FROM rustio_mfa_backup_codes WHERE user_id = $1")
        .bind(user_id)
        .execute(&mut *tx)
        .await?;

    // 6. Insert the new hashes. One round-trip per row keeps the
    //    code simple at the cost of N inserts; BACKUP_CODE_COUNT
    //    is 8, so the overhead is negligible (well under the
    //    Argon2id hashing cost we already paid above).
    for hash in &hashes {
        sqlx::query("INSERT INTO rustio_mfa_backup_codes (user_id, code_hash) VALUES ($1, $2)")
            .bind(user_id)
            .bind(hash)
            .execute(&mut *tx)
            .await?;
    }

    // 7. Commit. D3 atomicity guaranteed from here forward.
    tx.commit().await?;

    // 8. Audit emit AFTER commit succeeds (D8).
    let metadata = serde_json::json!({
        "previous_codes_invalidated": previous_count,
        "new_codes_count": BACKUP_CODE_COUNT,
    });
    let ip = client_ip(request);
    let mut entry = LogEntry::new(user_id, ActionType::Update, "users", user_id)
        .with_event(AuditEvent::BackupCodesRegenerated)
        .with_actor(user_id);
    entry.correlation_id = correlation_id;
    entry.ip_address = ip.as_deref();
    entry.metadata = Some(metadata);
    entry.summary = format!(
        "backup codes regenerated; {previous_count} previous invalidated; \
         {BACKUP_CODE_COUNT} new codes issued"
    );
    audit_record(db, entry).await?;

    Ok(RegenOutcome::Regenerated {
        plain_backup_codes: plain_codes,
        previous_codes_invalidated: previous_count,
    })
}

// -----------------------------------------------------------------
// Trust-escalation primitive (R3 commit #11)
// -----------------------------------------------------------------

// internal:
/// Promote a session from `authenticated` to `mfa_verified` via
/// token rotation per `DESIGN_SESSIONS.md` §11 + Doctrine 17.
///
/// Called by the verify POST handler after either
/// [`verify_totp_for_user`] or [`consume_backup_code`] returns
/// success. The function:
///
/// 1. Mints a fresh session row with:
///    - new random `token` + `token_hash`,
///    - `trust_level = 'mfa_verified'`,
///    - `parent_session_id = current_session_id` (the row that
///      was just MFA-verified — establishes the audit lineage),
///    - `user_id` unchanged,
///    - `expires_at = NOW() + 14 days`.
/// 2. Revokes the parent row via
///    `auth::sessions::invalidate_sessions` with
///    `SessionInvalidationReason::TrustEscalation`. Doctrine 22:
///    no direct `revoked_at` write here; the centralised
///    invalidator owns that.
/// 3. Returns the new plaintext token. The caller (handler)
///    sets it as the framework's session cookie, replacing the
///    pre-MFA token.
///
/// **Ordering rationale.** The new row is INSERTed before the
/// parent is revoked. A crash between the two operations leaves
/// the user with two active session rows (old + new) rather
/// than zero — the more recoverable failure mode. The next
/// request authenticates against either row; an over-permissive
/// transient state is preferable to a locked-out user. Future
/// commits may wrap the two writes in a single transaction
/// when [`invalidate_sessions`] gains a transaction-aware
/// variant.
///
/// **Doctrine 22.** This function inserts a new row (additive)
/// and delegates revocation to `invalidate_sessions`. No direct
/// `revoked_at` write. The grep proof remains intact.
///
/// **Doctrine 17.** Trust transitions rotate the token —
/// always. A `Copy`-trust upgrade in place (UPDATE the same
/// row's `trust_level`) would let a network-captured pre-MFA
/// token ride into the elevated state; the rotation forbids
/// that.
#[allow(dead_code)] // call site lands at the verify POST handler in a later commit
pub async fn promote_session_to_mfa_verified(
    db: &Db,
    current_session_id: i64,
    user_id: i64,
) -> Result<String> {
    let token = random_token();
    let token_hash = hash_token_for_storage(&token);
    let expires = Utc::now() + ChronoDuration::days(MFA_VERIFIED_SESSION_DAYS);

    // 1. Mint the new mfa_verified row with parent_session_id
    //    set. `token` (legacy) + `token_hash` both populated
    //    to match `auth::sessions::create_session` shape.
    sqlx::query(
        "INSERT INTO rustio_sessions \
         (token, token_hash, user_id, expires_at, trust_level, parent_session_id) \
         VALUES ($1, $2, $3, $4, 'mfa_verified', $5)",
    )
    .bind(&token)
    .bind(&token_hash)
    .bind(user_id)
    .bind(expires)
    .bind(current_session_id)
    .execute(db.pool())
    .await?;

    // 2. Revoke the parent via the centralised single writer.
    invalidate_sessions(
        db,
        SessionTarget::Single {
            session_id: current_session_id,
        },
        SessionInvalidationReason::TrustEscalation,
    )
    .await?;

    Ok(token)
}

// internal:
/// Variant of [`crate::auth::recovery_admin::promote_session_elevated`]
/// for the MFA-enrolled re-auth path. UPDATEs `elevated_until +
/// trust_level = 'mfa_verified'` in place (no token rotation).
///
/// The R2 `promote_session_elevated` unconditionally sets
/// `trust_level = 'elevated'`; calling it on a session that
/// was already `mfa_verified` (e.g. after the login-flow
/// verify step in commit #12 promoted it) would DOWNGRADE the
/// trust level. R3's re-auth path needs a sibling that
/// preserves / promotes-to `mfa_verified` instead.
///
/// **In-place UPDATE rationale (Doctrine 17 trade-off).** The
/// re-auth wall verifies BOTH factors before this UPDATE runs
/// — a cookie thief without the password (and TOTP, when
/// enrolled) cannot land here. Per DESIGN_R3_MFA.md §12.2,
/// re-auth is allowed to UPDATE `trust_level` in place rather
/// than rotate the token, because the user has already proved
/// both factors live in the current request. Full trust
/// escalation via token rotation lives in
/// [`promote_session_to_mfa_verified`] for the login-flow
/// verify path; the re-auth path stamps the same trust level
/// via UPDATE without a new cookie.
#[allow(dead_code)] // call site lands at /admin/reauth POST in R3 commit #17
pub(crate) async fn promote_session_mfa_elevated(
    db: &Db,
    session_id: i64,
    ttl: ChronoDuration,
) -> Result<()> {
    sqlx::query(
        "UPDATE rustio_sessions \
            SET elevated_until = NOW() + (INTERVAL '1 second' * $2::bigint), \
                trust_level = 'mfa_verified' \
          WHERE session_id = $1 AND revoked_at IS NULL",
    )
    .bind(session_id)
    .bind(ttl.num_seconds())
    .execute(db.pool())
    .await?;
    Ok(())
}

// public:
/// Framework-wide MFA enforcement policy.
///
/// Plain `Copy` enum (no trait object) — operators wire it onto
/// `Admin` via [`crate::admin::types::Admin::require_mfa`]. The
/// `login_guard` consults the active policy AFTER successful
/// password verification and AFTER R2's `must_change_password`
/// check (commit #15 of the R3 plan).
///
/// **Forward-only enforcement (D6).** Switching to
/// [`MfaPolicy::Required`] does NOT retroactively revoke
/// existing sessions. Existing users without MFA enrolled are
/// redirected to `/admin/mfa/enroll` at the next request that
/// hits `login_guard`. The pattern mirrors R2's
/// `must_change_password` interstitial.
///
/// **Default is [`MfaPolicy::Optional`].** R1 page copy contains
/// zero MFA mention; the doctrine-9 floor in DESIGN_RECOVERY
/// (email is convenience, not root of trust) sets the baseline.
/// Operators who want MFA enforcement opt in explicitly.
///
/// Typical project wiring:
///
/// ```ignore
/// use rustio_admin::auth::{MfaPolicy, Role};
///
/// // Enforce for everyone:
/// let admin = Admin::new().require_mfa(MfaPolicy::Required);
///
/// // Enforce for privileged roles only:
/// const PRIVILEGED: &[Role] = &[Role::Administrator, Role::Supervisor];
/// let admin = Admin::new().require_mfa(MfaPolicy::RequiredForRoles(PRIVILEGED));
///
/// // Reject MFA enrolment outright (e.g. for a public-kiosk admin):
/// let admin = Admin::new().require_mfa(MfaPolicy::Disabled);
/// ```
#[derive(Debug, Clone, Copy)]
pub enum MfaPolicy {
    /// MFA enrolment is rejected outright. Existing enrolments
    /// remain readable on the `rustio_users` row but the verify
    /// flow refuses to honour them. Used by deployments that
    /// have decided MFA is operationally inappropriate (kiosks,
    /// shared-credential workflows, etc.).
    Disabled,
    /// Default. Users may enrol; users without MFA can sign in
    /// with password alone. The pre-R3 framework behaviour.
    Optional,
    /// Every user must enrol. Forward-only — existing sessions
    /// remain valid; the `login_guard` redirects users without
    /// MFA to `/admin/mfa/enroll` at the next request.
    Required,
    /// Required only for users whose [`Role`] appears in the
    /// slice. Forward-only with the same semantics as
    /// [`MfaPolicy::Required`]. Empty slice is equivalent to
    /// [`MfaPolicy::Optional`] — the policy reads "no role
    /// requires MFA" rather than "no users require MFA".
    RequiredForRoles(&'static [Role]),
}

impl Default for MfaPolicy {
    /// [`MfaPolicy::Optional`] is the framework default. R1 page
    /// copy contains zero MFA mention; operators opt into
    /// enforcement explicitly via
    /// [`crate::admin::types::Admin::require_mfa`].
    fn default() -> Self {
        Self::Optional
    }
}

// internal:
/// Add the additive R3 MFA schema.
///
/// Adds four columns on `rustio_users`:
///
/// - `mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE` — the boolean
///   gate the login flow consults after password verification.
///   `FALSE` means MFA is not enrolled; the rest of the columns
///   are NULL. `TRUE` means the rest of the columns are
///   populated and `/admin/mfa/verify` is required to promote
///   the session to `mfa_verified`.
/// - `mfa_secret_ciphertext BYTEA` (nullable) — the AES-256-GCM
///   encrypted TOTP secret. Storage layout is
///   `nonce (12 bytes) || ciphertext || auth_tag (16 bytes)`.
///   Plaintext secret never reaches disk; decryption happens in
///   process memory during verification, scoped to the request
///   handler.
/// - `mfa_secret_key_id INT` (nullable) — which version of
///   `RUSTIO_SECRET_KEY` encrypted this row. Per-row stamp lets
///   key rotation proceed in stages: existing rows continue to
///   decrypt against their stamped key while new rows encrypt
///   against the active key. The retire-old-key sweep is a
///   future operational procedure (see §7 / Appendix E of the
///   design doc).
/// - `mfa_last_used_step BIGINT` (nullable) — the highest TOTP
///   step value previously accepted by `verify_totp`. Replay
///   protection (D4): a TOTP code from a step `≤
///   mfa_last_used_step` is rejected even if cryptographically
///   valid. Monotonic per user; never decrements.
///
/// Adds one new table for backup codes:
///
/// - `rustio_mfa_backup_codes` with `id BIGSERIAL`,
///   `user_id BIGINT NOT NULL REFERENCES rustio_users(id) ON
///   DELETE CASCADE`, `code_hash TEXT NOT NULL` (Argon2id,
///   low-memory params), `created_at TIMESTAMPTZ NOT NULL`,
///   `used_at TIMESTAMPTZ` (nullable; NULL = unused). The
///   `ON DELETE CASCADE` is the disable / account-deletion
///   contract — when the parent user disables MFA, the runtime
///   issues an explicit `DELETE` on these rows; when the user
///   row itself is deleted, cascade cleans up.
///
/// Plus a per-user partial index
/// `rustio_mfa_backup_codes_user_unused_idx ON (user_id) WHERE
/// used_at IS NULL` for the verification-path scan: at most 8
/// rows per user × the partial predicate makes the consume
/// scan an index seek to a tiny page.
///
/// **Backfill.** Existing `rustio_users` rows get the column
/// defaults: `mfa_enabled = FALSE`, all three NULL fields. No
/// pre-existing user is auto-enrolled. The new
/// `rustio_mfa_backup_codes` table is empty after the
/// migration.
///
/// **Rollback.** Rolling back to 0.6.0 (R2) is data-safe — the
/// columns and table become unreferenced; nothing hard-fails.
/// Forward migration is the supported direction; reverse is an
/// operator's snapshot-restore concern.
///
/// Idempotent. Safe to call on every boot. Depends on
/// `rustio_users` existing first (which `auth::init_tables`
/// guarantees by ordering this call after `init_user_tables`
/// and the R1 / R2 schema migrations).
pub(crate) async fn migrate_user_mfa_schema(db: &Db) -> Result<()> {
    sqlx::query(
        "ALTER TABLE rustio_users \
         ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE",
    )
    .execute(db.pool())
    .await?;

    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS mfa_secret_ciphertext BYTEA")
        .execute(db.pool())
        .await?;

    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS mfa_secret_key_id INT")
        .execute(db.pool())
        .await?;

    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS mfa_last_used_step BIGINT")
        .execute(db.pool())
        .await?;

    sqlx::query(
        "CREATE TABLE IF NOT EXISTS rustio_mfa_backup_codes ( \
            id          BIGSERIAL PRIMARY KEY, \
            user_id     BIGINT NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE, \
            code_hash   TEXT NOT NULL, \
            created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
            used_at     TIMESTAMPTZ \
         )",
    )
    .execute(db.pool())
    .await?;

    sqlx::query(
        "CREATE INDEX IF NOT EXISTS rustio_mfa_backup_codes_user_unused_idx \
         ON rustio_mfa_backup_codes (user_id) \
         WHERE used_at IS NULL",
    )
    .execute(db.pool())
    .await?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_is_optional() {
        assert!(matches!(MfaPolicy::default(), MfaPolicy::Optional));
    }

    #[test]
    fn policy_is_copy() {
        // Copy ensures the policy can be carried by value without
        // Arc indirection. The compiler enforces this at the
        // declaration site (`#[derive(Copy)]`); this test pins
        // the contract so a future field addition that breaks
        // Copy fails the suite, not just the next caller.
        const ROLES: &[Role] = &[Role::Administrator];
        let original = MfaPolicy::RequiredForRoles(ROLES);
        let copy = original;
        // Both bindings are usable — Copy.
        assert!(matches!(original, MfaPolicy::RequiredForRoles(_)));
        assert!(matches!(copy, MfaPolicy::RequiredForRoles(_)));
    }

    fn fixed_test_key() -> MfaKey {
        // Deterministic 32-byte key for round-trip tests. The
        // value is arbitrary — we just need a stable key across
        // wrap and unwrap calls.
        let mut bytes = [0u8; 32];
        for (i, b) in bytes.iter_mut().enumerate() {
            *b = (i as u8).wrapping_mul(7).wrapping_add(13);
        }
        MfaKey::from_bytes(bytes)
    }

    #[test]
    fn wrap_unwrap_round_trip_recovers_plaintext() {
        let key = fixed_test_key();
        let plaintext = b"hello-mfa-secret-20-bytes";
        let ciphertext = wrap_secret(plaintext, &key);

        // Storage layout: nonce (12) || ciphertext || tag (16).
        // Plaintext is 25 bytes ⇒ ciphertext_with_tag is 41 ⇒
        // total 53.
        assert_eq!(ciphertext.len(), 12 + plaintext.len() + 16);

        let recovered = unwrap_secret(&ciphertext, &key).expect("round-trip must decrypt");
        assert_eq!(recovered, plaintext);
    }

    #[test]
    fn wrap_uses_fresh_nonce_per_call() {
        // Two encryptions of the same plaintext under the same
        // key must NOT collide — fresh nonce per call. Without
        // this the AEAD's confidentiality breaks (same nonce +
        // same key + different plaintexts leaks XOR via known-
        // plaintext attacks).
        let key = fixed_test_key();
        let plaintext = b"identical-plaintext";
        let a = wrap_secret(plaintext, &key);
        let b = wrap_secret(plaintext, &key);
        assert_ne!(a, b, "fresh nonce per call must yield different ciphertext");
    }

    #[test]
    fn tampered_ciphertext_fails_aead_verification() {
        let key = fixed_test_key();
        let plaintext = b"sensitive-mfa-secret";
        let mut ciphertext = wrap_secret(plaintext, &key);

        // Flip a bit in the ciphertext body (post-nonce, pre-tag).
        ciphertext[20] ^= 0x01;
        let result = unwrap_secret(&ciphertext, &key);
        assert!(
            result.is_err(),
            "tampered ciphertext must fail AEAD verification"
        );
    }

    #[test]
    fn wrong_key_fails_decryption() {
        let key_enc = fixed_test_key();
        let key_dec = MfaKey::from_bytes([0xFFu8; 32]);
        let plaintext = b"wrong-key-test";
        let ciphertext = wrap_secret(plaintext, &key_enc);

        let result = unwrap_secret(&ciphertext, &key_dec);
        assert!(result.is_err(), "decrypt with wrong key must fail");
    }

    #[test]
    fn truncated_input_rejects_explicitly() {
        let key = fixed_test_key();
        // 27 bytes — one byte short of nonce + tag minimum.
        let too_short = [0u8; 27];
        let result = unwrap_secret(&too_short, &key);
        assert!(result.is_err(), "input below 28 bytes must reject");
    }

    // ---- backup codes (R3 commit #4) -----------------------------

    #[test]
    fn alphabet_is_31_chars_no_ambiguous() {
        // The 31-char alphabet excludes 0/O/1/I/L per
        // DESIGN_R3_MFA.md Appendix B. This test pins the
        // alphabet — if a future commit accidentally adds an
        // ambiguous character, the suite catches it.
        assert_eq!(BACKUP_CODE_ALPHABET.len(), 31);
        for &b in BACKUP_CODE_ALPHABET {
            let c = b as char;
            assert!(c.is_ascii_alphanumeric(), "non-alphanumeric: {c:?}");
            assert!(
                !matches!(c, '0' | 'O' | '1' | 'I' | 'L'),
                "ambiguous char in alphabet: {c:?}"
            );
        }
    }

    #[test]
    fn generate_returns_count_codes() {
        let codes = generate_backup_codes(BACKUP_CODE_COUNT);
        assert_eq!(codes.len(), BACKUP_CODE_COUNT);
    }

    #[test]
    fn each_code_is_xxxx_dash_xxxx_shape() {
        let codes = generate_backup_codes(8);
        for code in &codes {
            assert_eq!(code.len(), BACKUP_CODE_LEN + 1, "wrong length: {code:?}");
            assert_eq!(
                code.chars().nth(4),
                Some('-'),
                "hyphen missing at position 4: {code:?}"
            );
            // Every non-hyphen char is in the locked alphabet.
            for (i, c) in code.chars().enumerate() {
                if i == 4 {
                    continue;
                }
                assert!(
                    BACKUP_CODE_ALPHABET.contains(&(c as u8)),
                    "char {c:?} at position {i} not in alphabet"
                );
            }
        }
    }

    #[test]
    fn generated_codes_are_unique_within_batch() {
        // Birthday-bound for 8 codes from a 31^8 ≈ 9 × 10^11
        // space is well below the collision threshold. A repeated
        // code in a single batch would indicate the RNG is broken
        // or the alphabet is much smaller than expected.
        let codes = generate_backup_codes(64);
        let unique: std::collections::HashSet<_> = codes.iter().cloned().collect();
        assert_eq!(unique.len(), 64, "batch contained duplicates");
    }

    #[test]
    fn normalise_strips_hyphens_and_uppercases() {
        assert_eq!(normalise_backup_code("ABCD-EFGH"), "ABCDEFGH");
        assert_eq!(normalise_backup_code("abcd-efgh"), "ABCDEFGH");
        assert_eq!(normalise_backup_code("AbCdEfGh"), "ABCDEFGH");
        assert_eq!(normalise_backup_code(" abcd efgh "), "ABCDEFGH");
        assert_eq!(normalise_backup_code("abcdefgh"), "ABCDEFGH");
    }

    #[test]
    fn normalise_is_idempotent() {
        let once = normalise_backup_code("xxxx-yyyy");
        let twice = normalise_backup_code(&once);
        assert_eq!(once, twice);
    }

    #[test]
    fn hash_verify_round_trip() {
        let code = "ABCDEFGH";
        let hash = hash_backup_code(code).expect("hashing must succeed");
        assert!(verify_backup_code(code, &hash), "round-trip must verify");
    }

    #[test]
    fn hash_uses_argon2id_low_memory_params() {
        // Argon2's PHC string carries the params; this test pins
        // them so a future "let's tune Argon2" change either
        // updates the locked-decision table OR fails the suite
        // here.
        let hash = hash_backup_code("ABCDEFGH").expect("hash succeeds");
        assert!(hash.starts_with("$argon2id$"), "wrong algorithm: {hash}");
        assert!(
            hash.contains("m=16384,t=2,p=1"),
            "params drifted from locked m=16MB/t=2/p=1: {hash}"
        );
    }

    #[test]
    fn verify_rejects_wrong_code() {
        let hash = hash_backup_code("ABCDEFGH").expect("hash succeeds");
        assert!(!verify_backup_code("WRONGCDE", &hash));
    }

    #[test]
    fn verify_rejects_invalid_phc_string() {
        // Garbage hash must not panic — must return false.
        assert!(!verify_backup_code("ABCDEFGH", "not-a-phc-hash"));
        assert!(!verify_backup_code("ABCDEFGH", ""));
    }

    #[test]
    fn separate_hash_calls_yield_different_phc_strings() {
        // Fresh salt per call ⇒ same plaintext hashes differently.
        // Without this, an attacker who learns one hash trivially
        // recognises whether two users share the same code.
        let a = hash_backup_code("ABCDEFGH").expect("a");
        let b = hash_backup_code("ABCDEFGH").expect("b");
        assert_ne!(a, b, "fresh salt must produce different hashes");
        // But both still verify the original code.
        assert!(verify_backup_code("ABCDEFGH", &a));
        assert!(verify_backup_code("ABCDEFGH", &b));
    }

    // ---- TOTP RFC 6238 (R3 commit #5) ----------------------------

    /// RFC 6238 Appendix B test secret (20 ASCII bytes).
    const RFC6238_SECRET: &[u8] = b"12345678901234567890";

    #[test]
    fn current_step_at_canonical_30s_interval() {
        // T=0 → step 0; T=29 → step 0; T=30 → step 1; T=59 → step 1;
        // T=60 → step 2.
        assert_eq!(current_step(0, 30), 0);
        assert_eq!(current_step(29, 30), 0);
        assert_eq!(current_step(30, 30), 1);
        assert_eq!(current_step(59, 30), 1);
        assert_eq!(current_step(60, 30), 2);
    }

    #[test]
    fn rfc6238_appendix_b_test_vectors_truncated_to_6_digits() {
        // The RFC 6238 Appendix B vectors are 8-digit codes.
        // Authenticator apps render 6 digits by default, so the
        // framework's generate_totp returns the 6-digit form
        // (the last 6 digits of the 8-digit RFC value, since
        // truncation is `bin_code % 10^digits`).
        //
        // Source: RFC 6238 Appendix B, "TOTP Algorithm: Test
        // Vectors", SHA-1 column.
        //
        //  T (sec)        | 8-digit (RFC)  | 6-digit (this fn)
        //  ---------------+----------------+------------------
        //  59             | 94287082       | 287082
        //  1111111109     | 07081804       |  81804
        //  1111111111     | 14050471       |  50471
        //  1234567890     | 89005924       |   5924
        //  2000000000     | 69279037       | 279037
        //  20000000000    | 65353130       | 353130
        let cases: &[(u64, u32)] = &[
            (59, 287_082),
            (1_111_111_109, 81_804),
            (1_111_111_111, 50_471),
            (1_234_567_890, 5_924),
            (2_000_000_000, 279_037),
            (20_000_000_000, 353_130),
        ];

        for &(t, expected) in cases {
            let step = current_step(t, 30);
            let got = generate_totp(RFC6238_SECRET, step);
            assert_eq!(got, expected, "RFC 6238 vector at T={t} mismatched");
        }
    }

    #[test]
    fn generate_totp_returns_six_digit_range() {
        // Across a sample of steps, the result must fit in
        // [0, 999_999] — the modulo guarantees this but a future
        // refactor could lose the modulo silently.
        for step in [0u64, 1, 100, 12_345, u64::MAX] {
            let code = generate_totp(RFC6238_SECRET, step);
            assert!(
                code < 1_000_000,
                "code out of range for step {step}: {code}"
            );
        }
    }

    #[test]
    fn verify_accepts_current_step() {
        let t = 1_111_111_111u64;
        let step = current_step(t, 30);
        let code = generate_totp(RFC6238_SECRET, step);
        assert_eq!(verify_totp(RFC6238_SECRET, code, t, 30, 1), Some(step));
    }

    #[test]
    fn verify_accepts_one_step_skew() {
        // Generate at step S, verify at step S+1's wall-clock
        // (T += step_seconds). With skew=1, the previous step
        // is still accepted.
        let t_gen = 1_111_111_111u64;
        let step_gen = current_step(t_gen, 30);
        let code = generate_totp(RFC6238_SECRET, step_gen);

        let t_verify = t_gen + 30; // one step later
        let result = verify_totp(RFC6238_SECRET, code, t_verify, 30, 1);
        assert_eq!(result, Some(step_gen), "skew ±1 must accept previous step");
    }

    #[test]
    fn verify_rejects_two_step_skew_when_window_is_one() {
        // Generate at step S, verify at step S+2's wall-clock
        // with skew=1. Falls outside the [S+1, S+3] acceptance
        // window seen from T=S+2.
        let t_gen = 1_111_111_111u64;
        let step_gen = current_step(t_gen, 30);
        let code = generate_totp(RFC6238_SECRET, step_gen);

        let t_verify = t_gen + 60; // two steps later
        let result = verify_totp(RFC6238_SECRET, code, t_verify, 30, 1);
        assert_eq!(result, None, "skew=1 must reject two-step drift");
    }

    #[test]
    fn totp_verify_rejects_wrong_code() {
        let t = 1_111_111_111u64;
        let result = verify_totp(RFC6238_SECRET, 999_999, t, 30, 1);
        assert_eq!(result, None);
    }

    #[test]
    fn verify_does_not_underflow_at_t_zero() {
        // Skew window at T=0 would mathematically include step
        // -1; the saturating_add().max(0) guard maps it to step
        // 0, so the verify just retries step 0 instead of
        // panicking on integer underflow.
        let code = generate_totp(RFC6238_SECRET, 0);
        let result = verify_totp(RFC6238_SECRET, code, 0, 30, 1);
        assert_eq!(result, Some(0));
    }

    // ---- enrolment runtime (R3 commit #6) ------------------------

    #[test]
    fn base32_rfc4648_test_vector_foobar() {
        // RFC 4648 §10 standard test vector. Pins the encoder
        // against the canonical reference; if the alphabet or
        // bit-packing drifts, this test fails.
        assert_eq!(base32_encode_no_pad(b"foobar"), "MZXW6YTBOI");
    }

    #[test]
    fn base32_rfc4648_progressive_test_vectors() {
        // Additional RFC 4648 §10 vectors covering 1, 2, 3, 4,
        // 5 input bytes — exercises every path through the
        // bit-packing loop's leftover-bits flush.
        // (Outputs are the no-padding form; standard test
        // vectors include `=` padding which we strip per the
        // otpauth:// URL convention.)
        assert_eq!(base32_encode_no_pad(b"f"), "MY");
        assert_eq!(base32_encode_no_pad(b"fo"), "MZXQ");
        assert_eq!(base32_encode_no_pad(b"foo"), "MZXW6");
        assert_eq!(base32_encode_no_pad(b"foob"), "MZXW6YQ");
        assert_eq!(base32_encode_no_pad(b"fooba"), "MZXW6YTB");
    }

    #[test]
    fn provision_secret_returns_20_bytes() {
        let secret = provision_secret();
        assert_eq!(
            secret.secret_bytes.len(),
            20,
            "RFC 6238 default + universal authenticator-app interop"
        );
    }

    #[test]
    fn provision_secret_base32_length_matches_secret() {
        // 20 bytes × 8 bits = 160 bits / 5 bits per base32 char
        // = 32 chars exactly (no padding needed).
        let secret = provision_secret();
        assert_eq!(secret.base32.len(), 32);
        // Every char is in the base32 alphabet.
        for c in secret.base32.chars() {
            assert!(
                c.is_ascii_uppercase() || ('2'..='7').contains(&c),
                "non-base32 char in encoding: {c:?}"
            );
        }
    }

    #[test]
    fn provision_secret_each_call_yields_different_secret() {
        // Birthday-bound for 20-byte secrets is astronomical;
        // a collision in 16 calls indicates the RNG is broken.
        let mut seen = std::collections::HashSet::new();
        for _ in 0..16 {
            let secret = provision_secret();
            assert!(seen.insert(secret.secret_bytes), "RNG produced duplicate");
        }
    }

    // ---- enrolment URL + base32 decode (R3 commit #13) ----------

    #[test]
    fn build_otpauth_url_matches_google_authenticator_format() {
        // Standard otpauth:// URI per Google Authenticator's
        // Key URI Format spec. Issuer + account must appear
        // both in the path AND in the &issuer= query param;
        // the algorithm / digits / period are explicit so
        // apps that don't read defaults still get the right
        // values.
        let url = build_otpauth_url("Acme Corp", "alice@example.com", "MZXW6YTBOI", 30);
        assert!(
            url.starts_with("otpauth://totp/Acme%20Corp:alice%40example.com?"),
            "wrong path encoding: {url}"
        );
        assert!(url.contains("secret=MZXW6YTBOI"), "secret missing: {url}");
        assert!(
            url.contains("issuer=Acme%20Corp"),
            "issuer query missing: {url}"
        );
        assert!(url.contains("algorithm=SHA1"), "algorithm missing: {url}");
        assert!(url.contains("digits=6"), "digits missing: {url}");
        assert!(url.contains("period=30"), "period missing: {url}");
    }

    #[test]
    fn base32_decode_rfc4648_round_trips_progressive_vectors() {
        // Inverse of the base32_rfc4648_progressive_test_vectors
        // test from commit #6. The pair of tests pins the
        // encoder + decoder against each other AND against the
        // RFC 4648 spec.
        let cases: &[(&str, &[u8])] = &[
            ("MY", b"f"),
            ("MZXQ", b"fo"),
            ("MZXW6", b"foo"),
            ("MZXW6YQ", b"foob"),
            ("MZXW6YTB", b"fooba"),
            ("MZXW6YTBOI", b"foobar"),
        ];
        for &(encoded, expected) in cases {
            let decoded =
                base32_decode_no_pad(encoded).unwrap_or_else(|| panic!("decode failed: {encoded}"));
            assert_eq!(decoded.as_slice(), expected, "round-trip {encoded}");
        }
    }

    #[test]
    fn base32_decode_tolerates_hyphens_spaces_padding_and_lowercase() {
        // Users paste secrets in different shapes; the decoder
        // collapses them all to the canonical bytes.
        for variant in [
            "MZXW6YTBOI",
            "mzxw6ytboi",
            "MZXW 6YTB OI",
            "MZXW-6YTB-OI",
            "MZXW6YTBOI==",
        ] {
            assert_eq!(
                base32_decode_no_pad(variant).expect("decode should succeed"),
                b"foobar",
                "variant: {variant:?}"
            );
        }
    }

    #[test]
    fn base32_decode_rejects_non_alphabet_chars() {
        // The four ambiguity-rejected base32 letters (0, 1,
        // 8, 9) and other non-alphabet chars must return None
        // — not silently coerce. The handler maps None to a
        // uniform invalid-code response.
        assert!(base32_decode_no_pad("ABC0DEF").is_none());
        assert!(base32_decode_no_pad("ABC1DEF").is_none());
        assert!(base32_decode_no_pad("ABC8DEF").is_none());
        assert!(base32_decode_no_pad("ABC9DEF").is_none());
        assert!(base32_decode_no_pad("hello!").is_none());
    }
}