fix(auth): codes wait after ten wrong in a row, and the owner is told

Whoever knew the password could guess codes at 10 an hour forever,
unnoticed, each guess checked against every authenticator (the second
reading's M4). Every code an account's second factor refuses after a
right password (sign-in, a phrase's reset, a change) or from a signed-in
device (removing an authenticator, new recovery codes, the code from one
confirmed before) now counts, per account, from every network; after
every tenth in a row no code is taken (codesWait, saying until when), an
hour, then twice as long each time, a day at the most (longestCodeWait,
second_factor_waits.dart): at a day, ten guesses a day still have about a
1 % chance a year against one authenticator, so the first wait mails the
owner that someone knows their password. The instance keeps no address,
so sign-in and the phrase's reset now send the address the device typed
(and its language), used only for that mail after its hash is checked
against the account's. A code accepted clears it all.

Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits"
bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT
NULL) with a unique index on "authUserId": a new table, nothing
touched.

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 21:47:38 +02:00
commit 4a32f150db
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 42635 additions and 28 deletions

View file

@ -208,5 +208,6 @@
"passwordUnsafeCosts": "Die Instanz wollte dein Passwort auf eine Weise prüfen lassen, die kein Konto nutzt, daher wurde nichts gesendet. Passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.",
"passwordFailed": "Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal.",
"passwordMinLength": "Mindestens {count} Zeichen",
"passwordTooShort": "Ein neues Passwort braucht mindestens {count} Zeichen: Sollte die Datenbank der Instanz je nach außen gelangen, wird ein kurzes zuerst erraten."
"passwordTooShort": "Ein neues Passwort braucht mindestens {count} Zeichen: Sollte die Datenbank der Instanz je nach außen gelangen, wird ein kurzes zuerst erraten.",
"passwordCodesWait": "Zu viele falsche Codes hintereinander: Bis {date} wird kein Code angenommen. Waren es nicht deine, kennt jemand dein Passwort: Ändere es dann."
}

View file

@ -448,5 +448,13 @@
"type": "int"
}
}
},
"passwordCodesWait": "Too many wrong codes in a row: no code is taken until {date}. If they weren't yours, someone knows your password: change it then.",
"@passwordCodesWait": {
"placeholders": {
"date": {
"type": "String"
}
}
}
}

View file

@ -1356,6 +1356,12 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to:
/// **'A new password needs at least {count} characters: should the instance\'s database ever leak, a short one is guessed first.'**
String passwordTooShort(int count);
/// No description provided for @passwordCodesWait.
///
/// In en, this message translates to:
/// **'Too many wrong codes in a row: no code is taken until {date}. If they weren\'t yours, someone knows your password: change it then.'**
String passwordCodesWait(String date);
}
class _HalkyonAppLocalizationsDelegate

View file

@ -783,4 +783,9 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String passwordTooShort(int count) {
return 'Ein neues Passwort braucht mindestens $count Zeichen: Sollte die Datenbank der Instanz je nach außen gelangen, wird ein kurzes zuerst erraten.';
}
@override
String passwordCodesWait(String date) {
return 'Zu viele falsche Codes hintereinander: Bis $date wird kein Code angenommen. Waren es nicht deine, kennt jemand dein Passwort: Ändere es dann.';
}
}

View file

@ -766,6 +766,11 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
String passwordTooShort(int count) {
return 'A new password needs at least $count characters: should the instance\'s database ever leak, a short one is guessed first.';
}
@override
String passwordCodesWait(String date) {
return 'Too many wrong codes in a row: no code is taken until $date. If they weren\'t yours, someone knows your password: change it then.';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -110,6 +110,7 @@ class AccountRecoveryCubit(
newPassword: password,
totpCode: state.recoveryCode ? null : code,
recoveryCode: state.recoveryCode ? code : null,
language: _language,
);
await _passwords.enter(pending);
if (!isClosed) {

View file

@ -244,6 +244,7 @@ class PasswordSignInCubit(
recoveryCode: recoveryCode,
request: request,
code: code,
language: _language,
);
} on PasswordFailed catch (failed) {
if (failed.problem != PasswordProblem.setupIncomplete) {

View file

@ -45,7 +45,9 @@ abstract interface class PasswordGateway() {
Future<protocol.PasswordChallenge> begin(String address);
/// A sign-in's second step: [answer] to [challenge], with a TOTP code, a
/// recovery code, or a code mailed for [request] (a setup never finished).
/// recovery code, or a code mailed for [request] (a setup never finished);
/// [address] and [language] only so its owner is told should codes come
/// to wait.
Future<PasswordSignedIn> complete(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
@ -53,6 +55,8 @@ abstract interface class PasswordGateway() {
String? recoveryCode,
String? request,
String? code,
String? address,
String? language,
});
/// Signs up with the address a [code] mailed for [request] proves.
@ -147,6 +151,8 @@ abstract interface class PasswordGateway() {
required protocol.NewPassword password,
String? totpCode,
String? recoveryCode,
String? address,
String? language,
});
/// Asks for the reset of the account at [address]; when it takes effect.

View file

@ -144,6 +144,7 @@ class PasswordRepository(
String? recoveryCode,
String? request,
String? code,
String? language,
}) async {
final challenge = await _challengeOf(unlocked);
final answer = await _crypto.answer(
@ -158,6 +159,8 @@ class PasswordRepository(
recoveryCode: recoveryCode,
request: request,
code: code,
address: unlocked.address,
language: language,
);
final reply = signedIn.answer;
if (!answer.verifiesServer(reply.serverProof)) {
@ -427,6 +430,7 @@ class PasswordRepository(
required String newPassword,
String? totpCode,
String? recoveryCode,
String? language,
}) async {
final typed = address.trim();
final challenge = await _gateway.beginKeyProof(typed);
@ -446,6 +450,8 @@ class PasswordRepository(
password: password,
totpCode: totpCode?.replaceAll(' ', ''),
recoveryCode: recoveryCode,
address: typed,
language: language,
);
return PendingSignIn(
grant: grant,

View file

@ -60,6 +60,8 @@ class ServerpodPasswordGateway(
String? recoveryCode,
String? request,
String? code,
String? address,
String? language,
}) => _signedOut((base) async {
final signedIn = await base.modules.passwordSignIn.complete(
challenge: challenge.challenge,
@ -70,6 +72,8 @@ class ServerpodPasswordGateway(
request: _idOf(request),
code: code,
host: host,
address: address,
language: language,
);
return PasswordSignedIn(
grant: _grantOf(
@ -253,6 +257,8 @@ class ServerpodPasswordGateway(
required protocol.NewPassword password,
String? totpCode,
String? recoveryCode,
String? address,
String? language,
}) => _signedOut(
(base) async => _grantOf(
await base.modules.accountRecovery.resetPassword(
@ -262,6 +268,8 @@ class ServerpodPasswordGateway(
totpCode: totpCode,
recoveryCode: recoveryCode,
host: host,
address: address,
language: language,
),
),
);
@ -402,6 +410,7 @@ PasswordProblem _problemOf(protocol.PasswordRefusal reason) => switch (reason) {
// what the device proved is bound to that name, so nothing it says can
// be taken as the instance's.
protocol.PasswordRefusal.otherInstance => PasswordProblem.instanceUnproven,
protocol.PasswordRefusal.codesWait => PasswordProblem.codesWait,
};
/// Keeps one session in memory, for a client used once.

View file

@ -17,6 +17,10 @@ enum PasswordProblem() {
/// A reset waits: it takes effect at [PasswordFailed.takesEffectAt].
notDue,
/// Too many wrong codes in a row: none is taken until
/// [PasswordFailed.takesEffectAt].
codesWait,
/// No reset waits for the address.
noReset,

View file

@ -20,6 +20,10 @@ String passwordProblemText(BuildContext context, PasswordFailed failed) {
final at? => l10n.passwordNotDue(passwordMoment(context, at)),
null => l10n.passwordNotDueYet,
},
PasswordProblem.codesWait => switch (failed.takesEffectAt) {
final at? => l10n.passwordCodesWait(passwordMoment(context, at)),
null => l10n.passwordTooManyAttempts,
},
PasswordProblem.noReset => l10n.passwordNoReset,
PasswordProblem.passwordExists => l10n.passwordExists,
PasswordProblem.addressTaken => l10n.passwordAddressTaken,

View file

@ -43,6 +43,9 @@ final class FakePasswordAccount(
DateTime? authenticatorReset;
DateTime? accountReset;
/// Until when no code is taken, after too many wrong ones in a row.
DateTime? codesWaitUntil;
/// Whether the person signs in with GitHub.
bool gitHub = false;
@ -78,6 +81,10 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
/// The calls made, by name, in order.
final calls = <String>[];
/// The address and language the last sign-in's second step named, for
/// telling its owner should codes come to wait.
({String? address, String? language})? lastCompletedFor;
/// Thrown by every call while set.
PasswordFailed? failing;
@ -248,6 +255,12 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
}
throw const PasswordFailed(PasswordProblem.setupIncomplete);
}
final waitUntil = account.codesWaitUntil;
if (waitUntil != null &&
waitUntil.isAfter(now) &&
((totpCode ?? '').isNotEmpty || (recoveryCode ?? '').isNotEmpty)) {
throw PasswordFailed(PasswordProblem.codesWait, takesEffectAt: waitUntil);
}
if (totpCode != null && totpCode.isNotEmpty) {
if (totpCode == fakeTotpCode) {
account
@ -281,7 +294,10 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
String? recoveryCode,
String? request,
String? code,
String? address,
String? language,
}) => _call('complete', () {
lastCompletedFor = (address: address, language: language);
final account = _proved(challenge, answer);
final bool needsAuthenticator;
if (request != null && code != null && !account.finished) {
@ -605,6 +621,8 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
required protocol.NewPassword password,
String? totpCode,
String? recoveryCode,
String? address,
String? language,
}) => _call('resetPassword', () {
// The challenge names the account, whose phrase's proof key must have
// signed: never the account's own keys.