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

@ -0,0 +1,73 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_server/src/common/short_code.dart';
import 'package:halkyon_base_server/src/srp/srp_math.dart'
show constantTimeEquals;
/// Recovery codes (ADR 0017): ten one-time codes shown once when the second
/// factor is set up, each standing in for a TOTP code when the authenticator
/// is lost. Short codes as email sign-in mails them (`K7QX-M2PD`), kept only
/// as SHA-256 hashes.
/// How many codes a set has.
const recoveryCodeCount = 10;
/// A new set: the [codes] to show the person once, and the [hashes] to keep.
final class RecoveryCodes {
RecoveryCodes._(this.codes, this.hashes);
/// A fresh set of [recoveryCodeCount] codes.
factory RecoveryCodes.generate() {
final codes = [for (var i = 0; i < recoveryCodeCount; i++) newShortCode()];
return RecoveryCodes._(codes, [for (final code in codes) _hashOf(code)]);
}
/// The codes as the person sees them; never kept.
final List<String> codes;
/// Their hashes, in hex: what the register keeps.
final List<String> hashes;
}
/// Uses the code [typed] (as the person typed it: case, spaces, dashes and
/// O for 0 don't matter) against the account's unused [hashes]. Every hash
/// is compared, whether one matched or not. Accepted, the result holds the
/// hashes left, the used one gone: keep those before answering.
RecoveryCodeUse useRecoveryCode(String typed, List<String> hashes) {
final hash = _hashOf(typed);
int? matched;
for (var i = 0; i < hashes.length; i++) {
if (constantTimeEquals(utf8.encode(hashes[i]), utf8.encode(hash)) &&
matched == null) {
matched = i;
}
}
return matched == null
? const RecoveryCodeRefused()
: RecoveryCodeAccepted([
for (var i = 0; i < hashes.length; i++)
if (i != matched) hashes[i],
]);
}
String _hashOf(String code) =>
sha256.convert(utf8.encode(normalizeShortCode(code))).toString();
/// What a recovery code turned out to be.
sealed class RecoveryCodeUse {
const RecoveryCodeUse();
}
/// The code was one of the set: [remaining] are the hashes still unused.
final class RecoveryCodeAccepted extends RecoveryCodeUse {
const RecoveryCodeAccepted(this.remaining);
/// The hashes to keep from now on.
final List<String> remaining;
}
/// The code isn't one of the set, or was used before.
final class RecoveryCodeRefused extends RecoveryCodeUse {
const RecoveryCodeRefused();
}