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:
parent
3bc0bae6e3
commit
7ad0f92f23
1 changed files with 91 additions and 28 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue