feat(crypto): TOTP and recovery codes

ADR 0017's second factor, in halkyon_base_server (base.dart):
- RFC 6238 with HMAC-SHA1, 6 digits, 30-second steps, one step either
  way. checkTotp takes the account's last accepted step as a required
  argument and refuses a code of that step or before, so no code is
  accepted twice; every step of the window is computed and compared.
- 160-bit secrets in RFC 4648 Base32 (strict decoding); the otpauth
  URI with issuer and account escaped.
- Ten recovery codes of eight Crockford characters (short_code.dart),
  kept as SHA-256 hashes, each used once, every hash compared in
  constant time.
- Tests: RFC 6238 Appendix B (8 digits, SHA-1, SHA-256, SHA-512), the
  window, replay, RFC 4648's Base32 vectors, the recovery codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-20 12:01:52 +02:00
commit 470a0b633c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -127,6 +127,24 @@ compared in constant time. RFC 5054 Appendix B's vectors check both halves
(`SrpGroup.rfc5054TestVectorGroup`, SHA-1), and `test/unit/srp/srp_interop_test.dart` runs
the client against the register, every refusal included.
## TOTP and recovery codes (ADR 0017)
The second factor, in `package:halkyon_base_server/base.dart`:
- `newTotpSecret()`: 160 random bits, shown to the person in RFC 4648 Base32
(`base32Encode`, `base32Decode`) through `otpauthUri(issuer:, account:, secret:)`, its
label and issuer escaped (a space as `%20`).
- `checkTotp(secret:, code:, now:, lastAcceptedStep:)`: RFC 6238 with HMAC-SHA1, 6 digits,
30-second steps, one step either side of now. The account's last accepted step is a
required argument, and a code from that step or an earlier one is refused (`reused`),
so no code is accepted twice; `TotpAccepted.step` is the step to keep before answering.
Refusals: `malformed`, `wrong`, `reused`. `totpCode` makes a code (SHA-256 and SHA-512
for RFC 6238's vectors).
- `RecoveryCodes.generate()`: ten codes of eight Crockford characters (`K7QX-M2PD`, as
email sign-in's codes), shown once, kept as SHA-256 hashes; `useRecoveryCode(typed,
hashes)` compares every hash in constant time and gives the hashes left
(`RecoveryCodeAccepted.remaining`), so each code works once.
## Taking it into a host
1. Depend on both packages by path, within the repository's workspace (an app's