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
4 changed files with 690 additions and 1 deletions

View file

@ -2,7 +2,8 @@
/// (`HalkyonBase.configure`), how people authenticate, the account's
/// deletion and the pruning, email sign-in's helpers, the instance's
/// basics, people's public profiles and their pages, the instance's register
/// (ADR 0016), SRP's register side (ADR 0017), and the module's models.
/// (ADR 0016), SRP's register side, TOTP and recovery codes (ADR 0017), and
/// the module's models.
library;
export 'src/auth/account.dart';
@ -47,3 +48,6 @@ export 'src/srp/srp_server.dart'
SrpVerification,
srpChallenge,
srpVerify;
export 'src/totp/base32.dart';
export 'src/totp/recovery_codes.dart';
export 'src/totp/totp.dart';

View file

@ -0,0 +1,65 @@
import 'dart:typed_data';
/// RFC 4648's base 32 (section 6), as authenticator apps read a TOTP secret.
const _alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/// [bytes] in base 32, with the `=` padding RFC 4648 adds unless [padding]
/// is false (an `otpauth://` URI's secret goes without).
String base32Encode(List<int> bytes, {bool padding = true}) {
final out = StringBuffer();
var buffer = 0;
var bits = 0;
for (final byte in bytes) {
buffer = (buffer << 8) | (byte & 0xff);
bits += 8;
while (bits >= 5) {
out.write(_alphabet[(buffer >> (bits - 5)) & 31]);
bits -= 5;
}
buffer &= (1 << bits) - 1;
}
if (bits > 0) {
out.write(_alphabet[(buffer << (5 - bits)) & 31]);
}
if (padding) {
while (out.length % 8 != 0) {
out.write('=');
}
}
return out.toString();
}
/// The bytes [text] stands for in base 32, with its padding or without;
/// a [FormatException] for anything else (another letter, padding in the
/// middle, bits left over that aren't zero, a length no bytes give).
Uint8List base32Decode(String text) {
final trimmed = text.replaceFirst(RegExp(r'=+$'), '');
if (trimmed.contains('=')) {
throw FormatException('Padding inside base 32', text);
}
// Lengths of whole bytes: 8 characters per 5 bytes, a tail of 2, 4, 5 or 7.
if (const {1, 3, 6}.contains(trimmed.length % 8)) {
throw FormatException('No bytes have this length in base 32', text);
}
final out = <int>[];
var buffer = 0;
var bits = 0;
for (final character in trimmed.split('')) {
final value = _alphabet.indexOf(character);
if (value < 0) {
throw FormatException('Not base 32: $character', text);
}
buffer = (buffer << 5) | value;
bits += 5;
if (bits >= 8) {
out.add((buffer >> (bits - 8)) & 0xff);
bits -= 8;
}
buffer &= (1 << bits) - 1;
}
if (buffer != 0) {
throw FormatException('Base 32 with bits left over', text);
}
return Uint8List.fromList(out);
}

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

View file

@ -0,0 +1,171 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto;
import 'package:halkyon_base_server/src/srp/srp_math.dart'
show constantTimeEquals;
import 'package:halkyon_base_server/src/totp/base32.dart';
/// TOTP (RFC 6238, on HOTP, RFC 4226), the second factor every identified
/// account has (ADR 0017): codes of [totpDigits] digits from HMAC-SHA1 over
/// [totpPeriod]-long steps of Unix time, as authenticator apps make them.
/// The register keeps the secret (encrypted with the instance's `totpKey`),
/// and the last step it accepted, so no code is accepted twice.
/// How many digits a code has.
const totpDigits = 6;
/// How long one step lasts.
const totpPeriod = Duration(seconds: 30);
/// How many steps either side of now a code may be from: clocks and people
/// take a moment.
const totpWindow = 1;
/// How many random bits a new secret has: SHA-1's block (RFC 4226 asks for
/// at least 128, recommends 160).
const totpSecretBits = 160;
/// The HMAC a TOTP is made with: SHA-1 as authenticator apps use it; SHA-256
/// and SHA-512 for RFC 6238's other vectors.
enum TotpHash {
sha1('SHA1'),
sha256('SHA256'),
sha512('SHA512');
TotpHash(this.name);
/// Its name in an `otpauth://` URI.
final String name;
crypto.Hash get _hash => switch (this) {
TotpHash.sha1 => crypto.sha1,
TotpHash.sha256 => crypto.sha256,
TotpHash.sha512 => crypto.sha512,
};
}
/// A new secret of [totpSecretBits] random bits.
Uint8List newTotpSecret({Random? random}) {
final source = random ?? Random.secure();
return Uint8List.fromList([
for (var i = 0; i < totpSecretBits ~/ 8; i++) source.nextInt(256),
]);
}
/// The step [time] falls in: whole [totpPeriod]s since 1970 (UTC).
int totpStepAt(DateTime time) =>
time.toUtc().millisecondsSinceEpoch ~/ totpPeriod.inMilliseconds;
/// The code for [step] (RFC 4226's dynamic truncation), [digits] long with
/// leading zeros.
String totpCode(
List<int> secret, {
required int step,
int digits = totpDigits,
TotpHash hash = TotpHash.sha1,
}) {
final counter = ByteData(8)..setInt64(0, step);
final mac = crypto.Hmac(
hash._hash,
secret,
).convert(counter.buffer.asUint8List()).bytes;
final offset = mac.last & 0x0f;
final binary =
((mac[offset] & 0x7f) << 24) |
(mac[offset + 1] << 16) |
(mac[offset + 2] << 8) |
mac[offset + 3];
return (binary % pow(10, digits).toInt()).toString().padLeft(digits, '0');
}
/// Checks [code] against the account's [secret] at [now]. [lastAcceptedStep]
/// is the step of the last code the account's register accepted (null for
/// none yet): a code of that step or before is refused, so a code seen over
/// someone's shoulder can't be used again. Accepted, the result names the
/// step to keep as the new last one; keep it before answering.
TotpCheck checkTotp({
required List<int> secret,
required String code,
required DateTime now,
required int? lastAcceptedStep,
}) {
final typed = code.replaceAll(RegExp(r'\s'), '');
if (!RegExp('^[0-9]{$totpDigits}\$').hasMatch(typed)) {
return const TotpRefused(TotpRefusal.malformed);
}
final current = totpStepAt(now);
// Every step of the window is computed and compared, so how long it takes
// says nothing of which (if any) matched. A code may match two steps (one
// chance in a million): the earliest not yet used counts.
int? fresh;
var matchedAny = false;
for (var step = current - totpWindow; step <= current + totpWindow; step++) {
final matches = constantTimeEquals(
totpCode(secret, step: step).codeUnits,
typed.codeUnits,
);
matchedAny |= matches;
if (matches &&
fresh == null &&
(lastAcceptedStep == null || step > lastAcceptedStep)) {
fresh = step;
}
}
return switch ((fresh, matchedAny)) {
(final int step, _) => TotpAccepted(step),
(null, true) => const TotpRefused(TotpRefusal.reused),
(null, false) => const TotpRefused(TotpRefusal.wrong),
};
}
/// The `otpauth://totp/…` URI an authenticator app reads from a QR code:
/// the [account] at the [issuer] (the instance, `learn.halkyon.eu`), each
/// escaped (a space as `%20`, which authenticators read, never `+`), with
/// the secret in base 32 and how codes are made.
Uri otpauthUri({
required String issuer,
required String account,
required List<int> secret,
}) => Uri.parse(
'otpauth://totp/'
'${Uri.encodeComponent(issuer)}:${Uri.encodeComponent(account)}'
'?secret=${base32Encode(secret, padding: false)}'
'&issuer=${Uri.encodeComponent(issuer)}'
'&algorithm=${TotpHash.sha1.name}'
'&digits=$totpDigits'
'&period=${totpPeriod.inSeconds}',
);
/// What a code turned out to be.
sealed class TotpCheck {
const TotpCheck();
}
/// The code was right: [step] is now the account's last accepted one.
final class TotpAccepted extends TotpCheck {
const TotpAccepted(this.step);
/// The step to keep as the last accepted.
final int step;
}
/// The code wasn't accepted.
final class TotpRefused extends TotpCheck {
const TotpRefused(this.reason);
/// Why.
final TotpRefusal reason;
}
/// Why a code wasn't accepted.
enum TotpRefusal {
/// Not six digits.
malformed,
/// No step of the window makes it.
wrong,
/// Its step was accepted before.
reused,
}