halkyon/packages/halkyon_base_server/lib/src/totp/recovery_codes.dart
Tizian 「ティツィアーン」 7ad0f92f23
feat(crypto): recovery codes kept under the instance's key
A code holds 40 bits, which plain SHA-256 hashes in a leaked database
would give up to guessing; HMAC-SHA256 under a key of the instance kept
outside the database (recoveryCodeKey, at least 32 characters) gives
nothing to guess against without it. Decided 20 Oct 2026.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:10 +02:00

100 lines
3.3 KiB
Dart

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 HMAC-SHA256 under a key of the instance (`recoveryCodeKey`, decided 20
/// Oct 2026): a code holds 40 bits, which plain hashes in a leaked database
/// would give up to guessing, but the key is kept outside the database.
/// Losing the key only means people make new codes once signed in.
/// The shortest instance key taken: shorter, it could be guessed itself.
const recoveryCodeKeyMinLength = 32;
/// 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, kept under the instance's
/// [key].
factory RecoveryCodes.generate(String key) {
final hmac = _hmacOf(key);
final codes = [for (var i = 0; i < recoveryCodeCount; i++) newShortCode()];
return RecoveryCodes._(codes, [
for (final code in codes) _hashOf(code, hmac),
]);
}
/// The codes as the person sees them; never kept.
final List<String> codes;
/// Their keyed 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], made under
/// the instance's [key]. 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,
String key,
) {
final hash = _hashOf(typed, _hmacOf(key));
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],
]);
}
Hmac _hmacOf(String key) {
if (key.length < recoveryCodeKeyMinLength) {
throw ArgumentError.value(
'(${key.length} characters)',
'key',
"The recovery codes' key must be at least $recoveryCodeKeyMinLength "
'characters',
);
}
return Hmac(sha256, utf8.encode(key));
}
String _hashOf(String code, Hmac hmac) =>
hmac.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();
}