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
3 changed files with 91 additions and 28 deletions
|
|
@ -140,9 +140,11 @@ The second factor, in `package:halkyon_base_server/base.dart`:
|
||||||
so no code is accepted twice; `TotpAccepted.step` is the step to keep before answering.
|
so no code is accepted twice; `TotpAccepted.step` is the step to keep before answering.
|
||||||
Refusals: `malformed`, `wrong`, `reused`. `totpCode` makes a code (SHA-256 and SHA-512
|
Refusals: `malformed`, `wrong`, `reused`. `totpCode` makes a code (SHA-256 and SHA-512
|
||||||
for RFC 6238's vectors).
|
for RFC 6238's vectors).
|
||||||
- `RecoveryCodes.generate()`: ten codes of eight Crockford characters (`K7QX-M2PD`, as
|
- `RecoveryCodes.generate(key)`: ten codes of eight Crockford characters (`K7QX-M2PD`, as
|
||||||
email sign-in's codes), shown once, kept as SHA-256 hashes; `useRecoveryCode(typed,
|
email sign-in's codes), shown once, kept as HMAC-SHA256 under the instance's key
|
||||||
hashes)` compares every hash in constant time and gives the hashes left
|
(`recoveryCodeKey`, at least 32 characters, kept outside the database: a code's 40 bits
|
||||||
|
in a leaked database give nothing to guess against without it); `useRecoveryCode(typed,
|
||||||
|
hashes, key)` compares every hash in constant time and gives the hashes left
|
||||||
(`RecoveryCodeAccepted.remaining`), so each code works once.
|
(`RecoveryCodeAccepted.remaining`), so each code works once.
|
||||||
|
|
||||||
## Taking it into a host
|
## Taking it into a host
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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
|
/// 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
|
/// 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.
|
/// How many codes a set has.
|
||||||
const recoveryCodeCount = 10;
|
const recoveryCodeCount = 10;
|
||||||
|
|
@ -17,25 +23,34 @@ const recoveryCodeCount = 10;
|
||||||
final class RecoveryCodes {
|
final class RecoveryCodes {
|
||||||
RecoveryCodes._(this.codes, this.hashes);
|
RecoveryCodes._(this.codes, this.hashes);
|
||||||
|
|
||||||
/// A fresh set of [recoveryCodeCount] codes.
|
/// A fresh set of [recoveryCodeCount] codes, kept under the instance's
|
||||||
factory RecoveryCodes.generate() {
|
/// [key].
|
||||||
|
factory RecoveryCodes.generate(String key) {
|
||||||
|
final hmac = _hmacOf(key);
|
||||||
final codes = [for (var i = 0; i < recoveryCodeCount; i++) newShortCode()];
|
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.
|
/// The codes as the person sees them; never kept.
|
||||||
final List<String> codes;
|
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;
|
final List<String> hashes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Uses the code [typed] (as the person typed it: case, spaces, dashes and
|
/// 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
|
/// O for 0 don't matter) against the account's unused [hashes], made under
|
||||||
/// is compared, whether one matched or not. Accepted, the result holds the
|
/// the instance's [key]. Every hash is compared, whether one matched or not.
|
||||||
/// hashes left, the used one gone: keep those before answering.
|
/// Accepted, the result holds the hashes left, the used one gone: keep those
|
||||||
RecoveryCodeUse useRecoveryCode(String typed, List<String> hashes) {
|
/// before answering.
|
||||||
final hash = _hashOf(typed);
|
RecoveryCodeUse useRecoveryCode(
|
||||||
|
String typed,
|
||||||
|
List<String> hashes,
|
||||||
|
String key,
|
||||||
|
) {
|
||||||
|
final hash = _hashOf(typed, _hmacOf(key));
|
||||||
int? matched;
|
int? matched;
|
||||||
for (var i = 0; i < hashes.length; i++) {
|
for (var i = 0; i < hashes.length; i++) {
|
||||||
if (constantTimeEquals(utf8.encode(hashes[i]), utf8.encode(hash)) &&
|
if (constantTimeEquals(utf8.encode(hashes[i]), utf8.encode(hash)) &&
|
||||||
|
|
@ -51,8 +66,20 @@ RecoveryCodeUse useRecoveryCode(String typed, List<String> hashes) {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _hashOf(String code) =>
|
Hmac _hmacOf(String key) {
|
||||||
sha256.convert(utf8.encode(normalizeShortCode(code))).toString();
|
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.
|
/// What a recovery code turned out to be.
|
||||||
sealed class RecoveryCodeUse {
|
sealed class RecoveryCodeUse {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:halkyon_base_server/src/totp/recovery_codes.dart';
|
import 'package:halkyon_base_server/src/totp/recovery_codes.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
void main() {
|
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 '
|
test('a set has ten codes, as short codes are shown, and only their hashes '
|
||||||
'are kept', () {
|
'are kept', () {
|
||||||
final set = RecoveryCodes.generate();
|
final set = RecoveryCodes.generate(key);
|
||||||
expect(set.codes, hasLength(recoveryCodeCount));
|
expect(set.codes, hasLength(recoveryCodeCount));
|
||||||
expect(set.hashes, hasLength(recoveryCodeCount));
|
expect(set.hashes, hasLength(recoveryCodeCount));
|
||||||
for (final code in set.codes) {
|
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', () {
|
test('a code is accepted once: the hash left out of what to keep', () {
|
||||||
final set = RecoveryCodes.generate();
|
final set = RecoveryCodes.generate(key);
|
||||||
final use = useRecoveryCode(set.codes[3], set.hashes);
|
final use = useRecoveryCode(set.codes[3], set.hashes, key);
|
||||||
expect(use, isA<RecoveryCodeAccepted>());
|
expect(use, isA<RecoveryCodeAccepted>());
|
||||||
final remaining = (use as RecoveryCodeAccepted).remaining;
|
final remaining = (use as RecoveryCodeAccepted).remaining;
|
||||||
expect(remaining, hasLength(recoveryCodeCount - 1));
|
expect(remaining, hasLength(recoveryCodeCount - 1));
|
||||||
expect(remaining, isNot(contains(set.hashes[3])));
|
expect(remaining, isNot(contains(set.hashes[3])));
|
||||||
expect(
|
expect(
|
||||||
useRecoveryCode(set.codes[3], remaining),
|
useRecoveryCode(set.codes[3], remaining, key),
|
||||||
isA<RecoveryCodeRefused>(),
|
isA<RecoveryCodeRefused>(),
|
||||||
);
|
);
|
||||||
// The others still count.
|
// The others still count.
|
||||||
expect(
|
expect(
|
||||||
useRecoveryCode(set.codes[4], remaining),
|
useRecoveryCode(set.codes[4], remaining, key),
|
||||||
isA<RecoveryCodeAccepted>(),
|
isA<RecoveryCodeAccepted>(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('typed as people type: case, spaces, no dash, O for 0 and I or L for '
|
test('typed as people type: case, spaces, no dash, O for 0 and I or L for '
|
||||||
'1 count alike', () {
|
'1 count alike', () {
|
||||||
final set = RecoveryCodes.generate();
|
final set = RecoveryCodes.generate(key);
|
||||||
final code = set.codes.first;
|
final code = set.codes.first;
|
||||||
final typed = code
|
final typed = code
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replaceAll('-', ' ')
|
.replaceAll('-', ' ')
|
||||||
.replaceAll('0', 'o')
|
.replaceAll('0', 'o')
|
||||||
.replaceAll('1', 'l');
|
.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", () {
|
test("a code of another set, or no code at all, isn't accepted", () {
|
||||||
final set = RecoveryCodes.generate();
|
final set = RecoveryCodes.generate(key);
|
||||||
final other = RecoveryCodes.generate();
|
final other = RecoveryCodes.generate(key);
|
||||||
expect(
|
expect(
|
||||||
useRecoveryCode(other.codes.first, set.hashes),
|
useRecoveryCode(other.codes.first, set.hashes, key),
|
||||||
isA<RecoveryCodeRefused>(),
|
isA<RecoveryCodeRefused>(),
|
||||||
);
|
);
|
||||||
expect(useRecoveryCode('', set.hashes), isA<RecoveryCodeRefused>());
|
expect(useRecoveryCode('', set.hashes, key), isA<RecoveryCodeRefused>());
|
||||||
expect(
|
expect(
|
||||||
useRecoveryCode(set.codes.first, const []),
|
useRecoveryCode(set.codes.first, const [], key),
|
||||||
isA<RecoveryCodeRefused>(),
|
isA<RecoveryCodeRefused>(),
|
||||||
);
|
);
|
||||||
// A hash typed as a code is no code.
|
// A hash typed as a code is no code.
|
||||||
expect(
|
expect(
|
||||||
useRecoveryCode(set.hashes.first, set.hashes),
|
useRecoveryCode(set.hashes.first, set.hashes, key),
|
||||||
isA<RecoveryCodeRefused>(),
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue