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>
2026-10-20 12:01:52 +02:00
|
|
|
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
|
2026-10-20 12:28:56 +02:00
|
|
|
/// 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;
|
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>
2026-10-20 12:01:52 +02:00
|
|
|
|
|
|
|
|
/// 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);
|
|
|
|
|
|
2026-10-20 12:28:56 +02:00
|
|
|
/// A fresh set of [recoveryCodeCount] codes, kept under the instance's
|
|
|
|
|
/// [key].
|
|
|
|
|
factory RecoveryCodes.generate(String key) {
|
|
|
|
|
final hmac = _hmacOf(key);
|
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>
2026-10-20 12:01:52 +02:00
|
|
|
final codes = [for (var i = 0; i < recoveryCodeCount; i++) newShortCode()];
|
2026-10-20 12:28:56 +02:00
|
|
|
return RecoveryCodes._(codes, [
|
|
|
|
|
for (final code in codes) _hashOf(code, hmac),
|
|
|
|
|
]);
|
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>
2026-10-20 12:01:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The codes as the person sees them; never kept.
|
|
|
|
|
final List<String> codes;
|
|
|
|
|
|
2026-10-20 12:28:56 +02:00
|
|
|
/// Their keyed hashes, in hex: what the register keeps.
|
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>
2026-10-20 12:01:52 +02:00
|
|
|
final List<String> hashes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Uses the code [typed] (as the person typed it: case, spaces, dashes and
|
2026-10-20 12:28:56 +02:00
|
|
|
/// 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));
|
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>
2026-10-20 12:01:52 +02:00
|
|
|
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],
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 12:28:56 +02:00
|
|
|
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();
|
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>
2026-10-20 12:01:52 +02:00
|
|
|
|
|
|
|
|
/// 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();
|
|
|
|
|
}
|