73 lines
2.4 KiB
Dart
73 lines
2.4 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 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();
|
||
|
|
}
|