feat(crypto): TOTP and recovery codes
ADR 0017's second factor, in halkyon_base_server (base.dart): - RFC 6238 with HMAC-SHA1, 6 digits, 30-second steps, one step either way. checkTotp takes the account's last accepted step as a required argument and refuses a code of that step or before, so no code is accepted twice; every step of the window is computed and compared. - 160-bit secrets in RFC 4648 Base32 (strict decoding); the otpauth URI with issuer and account escaped. - Ten recovery codes of eight Crockford characters (short_code.dart), kept as SHA-256 hashes, each used once, every hash compared in constant time. - Tests: RFC 6238 Appendix B (8 digits, SHA-1, SHA-256, SHA-512), the window, replay, RFC 4648's Base32 vectors, the recovery codes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
bc499713b8
commit
470a0b633c
1 changed files with 690 additions and 1 deletions
|
|
@ -0,0 +1,73 @@
|
|||
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();
|
||||
}
|
||||
Loading…
Reference in a new issue