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:
parent
26488b40ce
commit
4a32f150db
8 changed files with 42635 additions and 28 deletions
|
|
@ -32,6 +32,14 @@ final class _Mailbox implements CodeMailer {
|
|||
required String language,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> sendCodesWaitNotice(
|
||||
Session session, {
|
||||
required String to,
|
||||
required DateTime until,
|
||||
required String language,
|
||||
}) async {}
|
||||
|
||||
/// The last code mailed to [address].
|
||||
String codeFor(String address) =>
|
||||
sent.lastWhere((mail) => mail.to == address).code;
|
||||
|
|
@ -55,6 +63,14 @@ final class _Refusing implements CodeMailer {
|
|||
required DateTime takesEffectAt,
|
||||
required String language,
|
||||
}) async => throw const SocketException('Connection refused');
|
||||
|
||||
@override
|
||||
Future<void> sendCodesWaitNotice(
|
||||
Session session, {
|
||||
required String to,
|
||||
required DateTime until,
|
||||
required String language,
|
||||
}) async => throw const SocketException('Connection refused');
|
||||
}
|
||||
|
||||
void main() {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@ final class _NoMail implements CodeMailer {
|
|||
required DateTime takesEffectAt,
|
||||
required String language,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> sendCodesWaitNotice(
|
||||
Session session, {
|
||||
required String to,
|
||||
required DateTime until,
|
||||
required String language,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import 'package:clock/clock.dart';
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_base_server/src/password/passwords.dart'
|
||||
show beginKeyProof, beginPasswordSignIn;
|
||||
import 'package:halkyon_base_server/src/password/second_factor_waits.dart'
|
||||
show codeWait;
|
||||
import 'package:halkyon_base_server/src/srp/srp_math.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
|
@ -751,6 +753,164 @@ void main() {
|
|||
await signIn(totpCode: codeOf(complete.enrolment, at: now));
|
||||
});
|
||||
|
||||
group('codes refused in a row', () {
|
||||
/// A sign-in with Anna's password from [network] and [totpCode]
|
||||
/// (a wrong one by default), the address typed as a device sends
|
||||
/// it.
|
||||
Future<PasswordSignedIn> tryCode({
|
||||
String network = '203.0.113.1',
|
||||
String totpCode = '000000',
|
||||
String? recoveryCode,
|
||||
}) => atNow(() async {
|
||||
final challenge = await beginPasswordSignIn(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
network: network,
|
||||
);
|
||||
final answer = device.answer(challenge);
|
||||
return await endpoints.passwordSignIn.complete(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: recoveryCode == null ? totpCode : null,
|
||||
recoveryCode: recoveryCode,
|
||||
host: testHost,
|
||||
address: 'Anna@example.org',
|
||||
language: 'de',
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> refuseTen(String network) async {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await expectLater(
|
||||
tryCode(network: network),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Matcher waitsUntil(DateTime until) => throwsA(
|
||||
isA<PasswordRefusedException>()
|
||||
.having((e) => e.reason, 'reason', PasswordRefusal.codesWait)
|
||||
.having((e) => e.takesEffectAt, 'until', until),
|
||||
);
|
||||
|
||||
test('wait an hour after ten, twice as long after ten more, the '
|
||||
'owner mailed once, at the first; even the right code waits; '
|
||||
'a code accepted clears it all', () async {
|
||||
final complete = await annaComplete();
|
||||
|
||||
await refuseTen('203.0.113.1');
|
||||
final firstUntil = now.add(const Duration(hours: 1));
|
||||
expect(mailbox.codesWait.single.to, 'anna@example.org');
|
||||
expect(mailbox.codesWait.single.language, 'de');
|
||||
expect(mailbox.codesWait.single.until, firstUntil);
|
||||
await expectLater(
|
||||
tryCode(
|
||||
network: '198.51.100.7',
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
),
|
||||
waitsUntil(firstUntil),
|
||||
);
|
||||
await expectLater(
|
||||
tryCode(
|
||||
network: '198.51.100.7',
|
||||
recoveryCode: complete.recoveryCodes.first,
|
||||
),
|
||||
waitsUntil(firstUntil),
|
||||
);
|
||||
|
||||
now = firstUntil.add(const Duration(seconds: 1));
|
||||
await refuseTen('192.0.2.9');
|
||||
await expectLater(
|
||||
tryCode(
|
||||
network: '198.51.100.8',
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
),
|
||||
waitsUntil(now.add(const Duration(hours: 2))),
|
||||
);
|
||||
expect(mailbox.codesWait, hasLength(1));
|
||||
|
||||
now = now.add(const Duration(hours: 2, seconds: 1));
|
||||
await tryCode(
|
||||
network: '198.51.100.9',
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
);
|
||||
expect(await SecondFactorFailures.db.count(session), 0);
|
||||
// Cleared: the next wait is an hour again, and mailed again.
|
||||
nextStep();
|
||||
await refuseTen('203.0.113.2');
|
||||
expect(mailbox.codesWait, hasLength(2));
|
||||
});
|
||||
|
||||
test('wait a day at the most', () {
|
||||
expect(codeWait(0), const Duration(hours: 1));
|
||||
expect(codeWait(1), const Duration(hours: 2));
|
||||
expect(codeWait(4), const Duration(hours: 16));
|
||||
expect(codeWait(5), const Duration(days: 1));
|
||||
expect(codeWait(60), const Duration(days: 1));
|
||||
});
|
||||
|
||||
test('are counted only after a right password: wrong passwords '
|
||||
'never make codes wait', () async {
|
||||
final complete = await annaComplete();
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await expectLater(
|
||||
atNow(() async {
|
||||
final challenge = await beginPasswordSignIn(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
network: '203.0.113.${i ~/ 5}',
|
||||
);
|
||||
final answer = device.answer(
|
||||
challenge,
|
||||
privateValue: BigInt.from(4242),
|
||||
);
|
||||
return await endpoints.passwordSignIn.complete(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: '000000',
|
||||
host: testHost,
|
||||
);
|
||||
}),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
expect(await SecondFactorFailures.db.count(session), 0);
|
||||
await tryCode(totpCode: codeOf(complete.enrolment, at: now));
|
||||
});
|
||||
|
||||
test('include those a signed-in device tries: removing an '
|
||||
'authenticator or new recovery codes', () async {
|
||||
final complete = await annaComplete();
|
||||
// Nine there (the signed-in tries are ten an hour, confirming
|
||||
// the authenticator took one), the tenth signing in.
|
||||
for (var i = 0; i < 9; i++) {
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.authenticator.newRecoveryCodes(
|
||||
asAnna(),
|
||||
code: '000000',
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCode),
|
||||
);
|
||||
}
|
||||
expect(mailbox.codesWait, isEmpty);
|
||||
await expectLater(
|
||||
tryCode(),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
tryCode(totpCode: codeOf(complete.enrolment, at: now)),
|
||||
waitsUntil(now.add(const Duration(hours: 1))),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('takes each recovery code once, in place of a code', () async {
|
||||
final complete = await annaComplete();
|
||||
final recoveryCode = complete.recoveryCodes.first;
|
||||
|
|
|
|||
|
|
@ -241,6 +241,8 @@ final class _Register implements AccountRegister {
|
|||
UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
String? address,
|
||||
String? language,
|
||||
}) {
|
||||
hosts.add(host);
|
||||
return _answer('completePasswordSignIn', _proven);
|
||||
|
|
@ -352,6 +354,8 @@ final class _Register implements AccountRegister {
|
|||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
String? address,
|
||||
String? language,
|
||||
}) => _answer(
|
||||
'resetPasswordWithPhrase',
|
||||
() => (account: _proven().account, endedBefore: endedBefore),
|
||||
|
|
|
|||
|
|
@ -42,6 +42,14 @@ final class _Mailbox implements CodeMailer {
|
|||
required String language,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> sendCodesWaitNotice(
|
||||
Session session, {
|
||||
required String to,
|
||||
required DateTime until,
|
||||
required String language,
|
||||
}) async {}
|
||||
|
||||
String codeFor(String address) =>
|
||||
sent.lastWhere((mail) => mail.to == address).code;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ final class Mailbox implements CodeMailer {
|
|||
})
|
||||
>[];
|
||||
|
||||
/// The notices that codes wait (ADR 0017).
|
||||
final codesWait = <({String to, DateTime until, String language})>[];
|
||||
|
||||
/// Whether it takes no mail, as a mail service down.
|
||||
bool refusing = false;
|
||||
|
||||
|
|
@ -49,6 +52,19 @@ final class Mailbox implements CodeMailer {
|
|||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> sendCodesWaitNotice(
|
||||
Session session, {
|
||||
required String to,
|
||||
required DateTime until,
|
||||
required String language,
|
||||
}) async {
|
||||
if (refusing) {
|
||||
throw Exception('Connection refused');
|
||||
}
|
||||
codesWait.add((to: to, until: until, language: language));
|
||||
}
|
||||
|
||||
/// The last code mailed to [address].
|
||||
String codeFor(String address) =>
|
||||
codes.lastWhere((mail) => mail.to == address).code;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ Future<void> deleteAll(Session session) async {
|
|||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await AccountReset.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await SecondFactorFailures.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await RecoveryCode.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await TotpAuthenticator.db.deleteWhere(
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -654,6 +654,8 @@ class _AccountRecoveryEndpoint {
|
|||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
String? address,
|
||||
String? language,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
|
|
@ -673,6 +675,8 @@ class _AccountRecoveryEndpoint {
|
|||
'totpCode': totpCode,
|
||||
'recoveryCode': recoveryCode,
|
||||
'host': host,
|
||||
'address': address,
|
||||
'language': language,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
|
|
@ -1168,6 +1172,8 @@ class _PasswordSignInEndpoint {
|
|||
_is.UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
String? address,
|
||||
String? language,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
|
|
@ -1189,6 +1195,8 @@ class _PasswordSignInEndpoint {
|
|||
'request': request,
|
||||
'code': code,
|
||||
'host': host,
|
||||
'address': address,
|
||||
'language': language,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue