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

@ -1,10 +1,16 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_server/src/totp/recovery_codes.dart';
import 'package:test/test.dart';
void main() {
/// The instance's key the hashes are made with (`recoveryCodeKey`).
const key = 'a key of the instance, kept outside its database';
test('a set has ten codes, as short codes are shown, and only their hashes '
'are kept', () {
final set = RecoveryCodes.generate();
final set = RecoveryCodes.generate(key);
expect(set.codes, hasLength(recoveryCodeCount));
expect(set.hashes, hasLength(recoveryCodeCount));
for (final code in set.codes) {
@ -22,51 +28,79 @@ void main() {
});
test('a code is accepted once: the hash left out of what to keep', () {
final set = RecoveryCodes.generate();
final use = useRecoveryCode(set.codes[3], set.hashes);
final set = RecoveryCodes.generate(key);
final use = useRecoveryCode(set.codes[3], set.hashes, key);
expect(use, isA<RecoveryCodeAccepted>());
final remaining = (use as RecoveryCodeAccepted).remaining;
expect(remaining, hasLength(recoveryCodeCount - 1));
expect(remaining, isNot(contains(set.hashes[3])));
expect(
useRecoveryCode(set.codes[3], remaining),
useRecoveryCode(set.codes[3], remaining, key),
isA<RecoveryCodeRefused>(),
);
// The others still count.
expect(
useRecoveryCode(set.codes[4], remaining),
useRecoveryCode(set.codes[4], remaining, key),
isA<RecoveryCodeAccepted>(),
);
});
test('typed as people type: case, spaces, no dash, O for 0 and I or L for '
'1 count alike', () {
final set = RecoveryCodes.generate();
final set = RecoveryCodes.generate(key);
final code = set.codes.first;
final typed = code
.toLowerCase()
.replaceAll('-', ' ')
.replaceAll('0', 'o')
.replaceAll('1', 'l');
expect(useRecoveryCode(typed, set.hashes), isA<RecoveryCodeAccepted>());
expect(
useRecoveryCode(typed, set.hashes, key),
isA<RecoveryCodeAccepted>(),
);
});
test("a code of another set, or no code at all, isn't accepted", () {
final set = RecoveryCodes.generate();
final other = RecoveryCodes.generate();
final set = RecoveryCodes.generate(key);
final other = RecoveryCodes.generate(key);
expect(
useRecoveryCode(other.codes.first, set.hashes),
useRecoveryCode(other.codes.first, set.hashes, key),
isA<RecoveryCodeRefused>(),
);
expect(useRecoveryCode('', set.hashes), isA<RecoveryCodeRefused>());
expect(useRecoveryCode('', set.hashes, key), isA<RecoveryCodeRefused>());
expect(
useRecoveryCode(set.codes.first, const []),
useRecoveryCode(set.codes.first, const [], key),
isA<RecoveryCodeRefused>(),
);
// A hash typed as a code is no code.
expect(
useRecoveryCode(set.hashes.first, set.hashes),
useRecoveryCode(set.hashes.first, set.hashes, key),
isA<RecoveryCodeRefused>(),
);
});
test("kept under the instance's key: the same codes under another key, as "
"a leaked database's hashes without the key, are worth nothing", () {
final set = RecoveryCodes.generate(key);
const otherKey = 'another instance key, just as long as the first';
expect(
useRecoveryCode(set.codes.first, set.hashes, otherKey),
isA<RecoveryCodeRefused>(),
);
// Plain SHA-256 of a code, as a database without the key would let one
// check guesses against, is none of the hashes.
expect(
set.hashes,
isNot(contains(sha256.convert(utf8.encode(set.codes.first)).toString())),
);
});
test('a key too short to keep the hashes from being guessed is refused', () {
expect(() => RecoveryCodes.generate(''), throwsArgumentError);
expect(() => RecoveryCodes.generate('short'), throwsArgumentError);
expect(
() => useRecoveryCode('K7QX-M2PD', const [], 'short'),
throwsArgumentError,
);
});
}