feat(crypto): recovery codes kept under the instance's key

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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-20 12:28:56 +02:00
commit 7ad0f92f23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -8,7 +8,13 @@ import 'package:halkyon_base_server/src/srp/srp_math.dart'
/// 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.
/// 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;
@ -17,25 +23,34 @@ const recoveryCodeCount = 10;
final class RecoveryCodes {
RecoveryCodes._(this.codes, this.hashes);
/// A fresh set of [recoveryCodeCount] codes.
factory RecoveryCodes.generate() {
/// 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)]);
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 hashes, in hex: what the register keeps.
/// 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]. 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);
/// 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)) &&
@ -51,8 +66,20 @@ RecoveryCodeUse useRecoveryCode(String typed, List<String> hashes) {
]);
}
String _hashOf(String code) =>
sha256.convert(utf8.encode(normalizeShortCode(code))).toString();
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 {