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
3 changed files with 42635 additions and 28 deletions
|
|
@ -261,7 +261,10 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
|
|||
/// Sets a new [password] with the recovery phrase's [signature] for
|
||||
/// [challenge] and a [totpCode] or [recoveryCode]; the keys stay. Every
|
||||
/// session of the account ends, in every service of the instance, and
|
||||
/// this one signs in. Refusals as `resetPasswordWithPhrase`'s.
|
||||
/// this one signs in. [host] is the host the device addressed this
|
||||
/// instance by, which the phrase's proof signs; [address] and [language],
|
||||
/// as the device typed them, serve only to tell the account's owner
|
||||
/// should codes come to wait. Refusals as `resetPasswordWithPhrase`'s.
|
||||
_ida.Future<_iacc.AuthSuccess> resetPassword({
|
||||
required _isc.UuidValue challenge,
|
||||
required _idt.ByteData signature,
|
||||
|
|
@ -269,6 +272,8 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
|
|||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
String? address,
|
||||
String? language,
|
||||
}) => caller.callServerEndpoint<_iacc.AuthSuccess>(
|
||||
'halkyon_base.accountRecovery',
|
||||
'resetPassword',
|
||||
|
|
@ -279,6 +284,8 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
|
|||
'totpCode': totpCode,
|
||||
'recoveryCode': recoveryCode,
|
||||
'host': host,
|
||||
'address': address,
|
||||
'language': language,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -521,7 +528,11 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
|
|||
/// (or, for a setup never finished, a [code] mailed for [request] to the
|
||||
/// address the sign-in began with). Signed in, SRP's M2 (which the device checks before trusting anything
|
||||
/// else), the account's keys as the register keeps them, and the session.
|
||||
/// Refusals as `completePasswordSignIn`'s.
|
||||
/// [host] is the host the device addressed this instance by, which its
|
||||
/// proof is bound to; [address] and [language], the address the sign-in
|
||||
/// began with as the device typed it, serve only to tell its owner should
|
||||
/// codes come to wait (the instance keeps no address). Refusals as
|
||||
/// `completePasswordSignIn`'s.
|
||||
_ida.Future<_ix90myvx.PasswordSignedIn> complete({
|
||||
required _isc.UuidValue challenge,
|
||||
required _idt.ByteData clientValue,
|
||||
|
|
@ -531,6 +542,8 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
|
|||
_isc.UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
String? address,
|
||||
String? language,
|
||||
}) => caller.callServerEndpoint<_ix90myvx.PasswordSignedIn>(
|
||||
'halkyon_base.passwordSignIn',
|
||||
'complete',
|
||||
|
|
@ -543,6 +556,8 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
|
|||
'request': request,
|
||||
'code': code,
|
||||
'host': host,
|
||||
'address': address,
|
||||
'language': language,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,11 @@ enum PasswordRefusal implements _isc.SerializableModel {
|
|||
/// The device addressed this instance by another name than its own
|
||||
/// (`apiServer.publicHost`): what it proves is bound to the name it
|
||||
/// addressed, so the operator must configure the name devices use.
|
||||
otherInstance;
|
||||
otherInstance,
|
||||
|
||||
/// Too many codes were refused in a row: no code is taken until
|
||||
/// `takesEffectAt` (the second reading's M4). Only after a right password.
|
||||
codesWait;
|
||||
|
||||
static PasswordRefusal fromJson(String name) {
|
||||
switch (name) {
|
||||
|
|
@ -118,6 +122,8 @@ enum PasswordRefusal implements _isc.SerializableModel {
|
|||
return PasswordRefusal.wrongExistingCode;
|
||||
case 'otherInstance':
|
||||
return PasswordRefusal.otherInstance;
|
||||
case 'codesWait':
|
||||
return PasswordRefusal.codesWait;
|
||||
default:
|
||||
throw ArgumentError(
|
||||
'Value "$name" cannot be converted to "PasswordRefusal"',
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ abstract class PasswordRefusedException
|
|||
_i647hpdf.PasswordRefusal reason;
|
||||
|
||||
/// When the reset waiting takes effect, for [PasswordRefusal.notDue];
|
||||
/// null otherwise.
|
||||
/// when codes are taken again, for [PasswordRefusal.codesWait]; null
|
||||
/// otherwise.
|
||||
DateTime? takesEffectAt;
|
||||
|
||||
/// Returns a shallow copy of this [PasswordRefusedException]
|
||||
|
|
|
|||
Loading…
Reference in a new issue