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 codes; /// Their keyed hashes, in hex: what the register keeps. final List 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 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 remaining; } /// The code isn't one of the set, or was used before. final class RecoveryCodeRefused extends RecoveryCodeUse { const RecoveryCodeRefused(); }