feat(auth): a session ended for its wrong proofs warns its owner

The tenth wrong owner proof in a row ends a session (the third
reading); someone holding that session tried the password or its
codes. The register now keeps the moment with the account's password
(`account_password.sessionEndedForProofsAt`): the account's sign-in
ways and its next password sign-in carry it, until the owner dismisses
it (`password.dismissSessionEndedWarning`, their session alone: it
hides a warning and secures nothing) or sets a new password. A member
ending its own session tells the register
(`noteSessionEndedForProofs`); a register out of reach leaves the
owner unwarned, the session ended all the same.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_password" ADD COLUMN "sessionEndedForProofsAt"
timestamp without time zone; nullable, so every row keeps its values
and has no warning.

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-21 12:05:18 +02:00
commit 5e7d7f4e36
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 41919 additions and 34 deletions

View file

@ -22,6 +22,7 @@ abstract class SignInWays
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
this.sessionEndedForProofsAt,
}) : password = password ?? false,
authenticator = authenticator ?? false,
passwordOnly = passwordOnly ?? false,
@ -34,6 +35,7 @@ abstract class SignInWays
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt,
}) = _SignInWaysImpl;
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
@ -54,6 +56,12 @@ abstract class SignInWays
: _isc.BoolJsonExtension.fromJson(
jsonSerialization['secondFactorWorks'],
),
sessionEndedForProofsAt:
jsonSerialization['sessionEndedForProofsAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionEndedForProofsAt'],
),
);
}
@ -77,6 +85,11 @@ abstract class SignInWays
/// takes a code by mail in place of a TOTP or recovery code.
bool secondFactorWorks;
/// When a session of the account was last ended for ten wrong proofs in
/// a row, the owner's warning until they dismiss it or set a new
/// password (ADR 0017, the fourth reading); null for none.
DateTime? sessionEndedForProofsAt;
/// Returns a shallow copy of this [SignInWays]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@ -87,6 +100,7 @@ abstract class SignInWays
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt,
});
@override
Map<String, dynamic> toJson() {
@ -98,6 +112,8 @@ abstract class SignInWays
'authenticator': authenticator,
'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
};
}
@ -111,6 +127,8 @@ abstract class SignInWays
'authenticator': authenticator,
'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
};
}
@ -120,6 +138,8 @@ abstract class SignInWays
}
}
class _Undefined {}
class _SignInWaysImpl extends SignInWays {
_SignInWaysImpl({
required bool gitHub,
@ -128,6 +148,7 @@ class _SignInWaysImpl extends SignInWays {
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt,
}) : super._(
gitHub: gitHub,
email: email,
@ -135,6 +156,7 @@ class _SignInWaysImpl extends SignInWays {
authenticator: authenticator,
passwordOnly: passwordOnly,
secondFactorWorks: secondFactorWorks,
sessionEndedForProofsAt: sessionEndedForProofsAt,
);
/// Returns a shallow copy of this [SignInWays]
@ -148,6 +170,7 @@ class _SignInWaysImpl extends SignInWays {
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
Object? sessionEndedForProofsAt = _Undefined,
}) {
return SignInWays(
gitHub: gitHub ?? this.gitHub,
@ -156,6 +179,9 @@ class _SignInWaysImpl extends SignInWays {
authenticator: authenticator ?? this.authenticator,
passwordOnly: passwordOnly ?? this.passwordOnly,
secondFactorWorks: secondFactorWorks ?? this.secondFactorWorks,
sessionEndedForProofsAt: sessionEndedForProofsAt is DateTime?
? sessionEndedForProofsAt
: this.sessionEndedForProofsAt,
);
}
}

View file

@ -506,6 +506,17 @@ class EndpointPassword extends _isc.EndpointRef {
'recoveryProofKey': recoveryProofKey,
},
);
/// Dismisses the caller's warning that a session of theirs was ended for
/// ten wrong proofs in a row (`account.signInWays`'
/// `sessionEndedForProofsAt`). Their session alone does: it hides a
/// warning and changes nothing about how the account is secured.
_ida.Future<void> dismissSessionEndedWarning() =>
caller.callServerEndpoint<void>(
'halkyon_base.password',
'dismissSessionEndedWarning',
{},
);
}
/// Signing in with an address, a password and a code from an authenticator

View file

@ -29,6 +29,7 @@ abstract class PasswordSignedIn
required this.signingPublicKey,
required this.passwordKeys,
required this.needsAuthenticator,
this.sessionEndedForProofsAt,
required this.authStrategy,
required this.token,
this.tokenExpiresAt,
@ -43,6 +44,7 @@ abstract class PasswordSignedIn
required _idt.ByteData signingPublicKey,
required _i2rxumte.WrappedKeys passwordKeys,
required bool needsAuthenticator,
DateTime? sessionEndedForProofsAt,
required String authStrategy,
required String token,
DateTime? tokenExpiresAt,
@ -68,6 +70,12 @@ abstract class PasswordSignedIn
needsAuthenticator: _isc.BoolJsonExtension.fromJson(
jsonSerialization['needsAuthenticator'],
),
sessionEndedForProofsAt:
jsonSerialization['sessionEndedForProofsAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionEndedForProofsAt'],
),
authStrategy: jsonSerialization['authStrategy'] as String,
token: jsonSerialization['token'] as String,
tokenExpiresAt: jsonSerialization['tokenExpiresAt'] == null
@ -95,6 +103,11 @@ abstract class PasswordSignedIn
bool needsAuthenticator;
/// When a session of the account was last ended for ten wrong proofs in
/// a row (someone with a session tried the password or its codes), until
/// the owner dismisses the warning or sets a new password; null for none.
DateTime? sessionEndedForProofsAt;
/// The session, as Serverpod's `AuthSuccess` has it (the device makes one
/// of these fields for its session manager).
String authStrategy;
@ -118,6 +131,7 @@ abstract class PasswordSignedIn
_idt.ByteData? signingPublicKey,
_i2rxumte.WrappedKeys? passwordKeys,
bool? needsAuthenticator,
DateTime? sessionEndedForProofsAt,
String? authStrategy,
String? token,
DateTime? tokenExpiresAt,
@ -134,6 +148,8 @@ abstract class PasswordSignedIn
'signingPublicKey': signingPublicKey.toJson(),
'passwordKeys': passwordKeys.toJson(),
'needsAuthenticator': needsAuthenticator,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
'authStrategy': authStrategy,
'token': token,
if (tokenExpiresAt != null) 'tokenExpiresAt': tokenExpiresAt?.toJson(),
@ -152,6 +168,8 @@ abstract class PasswordSignedIn
'signingPublicKey': signingPublicKey.toJson(),
'passwordKeys': passwordKeys.toJsonForProtocol(),
'needsAuthenticator': needsAuthenticator,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
'authStrategy': authStrategy,
'token': token,
if (tokenExpiresAt != null) 'tokenExpiresAt': tokenExpiresAt?.toJson(),
@ -176,6 +194,7 @@ class _PasswordSignedInImpl extends PasswordSignedIn {
required _idt.ByteData signingPublicKey,
required _i2rxumte.WrappedKeys passwordKeys,
required bool needsAuthenticator,
DateTime? sessionEndedForProofsAt,
required String authStrategy,
required String token,
DateTime? tokenExpiresAt,
@ -188,6 +207,7 @@ class _PasswordSignedInImpl extends PasswordSignedIn {
signingPublicKey: signingPublicKey,
passwordKeys: passwordKeys,
needsAuthenticator: needsAuthenticator,
sessionEndedForProofsAt: sessionEndedForProofsAt,
authStrategy: authStrategy,
token: token,
tokenExpiresAt: tokenExpiresAt,
@ -206,6 +226,7 @@ class _PasswordSignedInImpl extends PasswordSignedIn {
_idt.ByteData? signingPublicKey,
_i2rxumte.WrappedKeys? passwordKeys,
bool? needsAuthenticator,
Object? sessionEndedForProofsAt = _Undefined,
String? authStrategy,
String? token,
Object? tokenExpiresAt = _Undefined,
@ -220,6 +241,9 @@ class _PasswordSignedInImpl extends PasswordSignedIn {
signingPublicKey: signingPublicKey ?? this.signingPublicKey.clone(),
passwordKeys: passwordKeys ?? this.passwordKeys.copyWith(),
needsAuthenticator: needsAuthenticator ?? this.needsAuthenticator,
sessionEndedForProofsAt: sessionEndedForProofsAt is DateTime?
? sessionEndedForProofsAt
: this.sessionEndedForProofsAt,
authStrategy: authStrategy ?? this.authStrategy,
token: token ?? this.token,
tokenExpiresAt: tokenExpiresAt is DateTime?