halkyon/packages/halkyon_base_server/lib/src/totp/recovery_codes.dart

73 lines
2.4 KiB
Dart
Raw Normal View History

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