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>
100 lines
3.3 KiB
Dart
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();
|
|
}
|