fix(auth): only a session made since dismisses the owner's warning

The full reading's L1: the warning that ten wrong proofs ended a session is
there for the owner to learn of it, and any other session of the account
(another app on the same stolen phone) dismissed it. Now only a session
made after the warning dismisses it (signed in again, or the sign-in that
first shows it); an older one is refused (PasswordRefusal.signedInBefore,
its own text in English and German), and a new password clears it as
before. A member tells the register when its session asking was made.

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 13:32:33 +02:00
commit 003cb4821a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
20 changed files with 165 additions and 24 deletions

View file

@ -232,5 +232,6 @@
"passwordResetsCallOff": "Absagen", "passwordResetsCallOff": "Absagen",
"passwordCallOffTitle": "Zurücksetzen absagen", "passwordCallOffTitle": "Zurücksetzen absagen",
"passwordCallOffIntro": "Dein Passwort und ein Code sagen jedes anstehende Zurücksetzen ab.", "passwordCallOffIntro": "Dein Passwort und ein Code sagen jedes anstehende Zurücksetzen ab.",
"passwordSectionGitHubEndsDeletes": "Die Anmeldung mit GitHub endet am {date}. Dann wird dein Konto mit allem darin gelöscht, es sei denn, du richtest vorher ein Passwort ein." "passwordSectionGitHubEndsDeletes": "Die Anmeldung mit GitHub endet am {date}. Dann wird dein Konto mit allem darin gelöscht, es sei denn, du richtest vorher ein Passwort ein.",
"passwordSignedInBefore": "Nur ein seitdem angemeldetes Gerät kann diese Warnung ausblenden: Melde dich auf diesem neu an oder ändere dein Passwort."
} }

View file

@ -507,5 +507,6 @@
"type": "String" "type": "String"
} }
} }
} },
"passwordSignedInBefore": "Only a device signed in since then can dismiss this warning: sign in again on this one, or change your password."
} }

View file

@ -1500,6 +1500,12 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Sign-in with GitHub ends on {date}. Your account, with everything in it, will be deleted then, unless you set up a password before.'** /// **'Sign-in with GitHub ends on {date}. Your account, with everything in it, will be deleted then, unless you set up a password before.'**
String passwordSectionGitHubEndsDeletes(String date); String passwordSectionGitHubEndsDeletes(String date);
/// No description provided for @passwordSignedInBefore.
///
/// In en, this message translates to:
/// **'Only a device signed in since then can dismiss this warning: sign in again on this one, or change your password.'**
String get passwordSignedInBefore;
} }
class _HalkyonAppLocalizationsDelegate class _HalkyonAppLocalizationsDelegate

View file

@ -874,4 +874,8 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String passwordSectionGitHubEndsDeletes(String date) { String passwordSectionGitHubEndsDeletes(String date) {
return 'Die Anmeldung mit GitHub endet am $date. Dann wird dein Konto mit allem darin gelöscht, es sei denn, du richtest vorher ein Passwort ein.'; return 'Die Anmeldung mit GitHub endet am $date. Dann wird dein Konto mit allem darin gelöscht, es sei denn, du richtest vorher ein Passwort ein.';
} }
@override
String get passwordSignedInBefore =>
'Nur ein seitdem angemeldetes Gerät kann diese Warnung ausblenden: Melde dich auf diesem neu an oder ändere dein Passwort.';
} }

View file

@ -857,6 +857,10 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
String passwordSectionGitHubEndsDeletes(String date) { String passwordSectionGitHubEndsDeletes(String date) {
return 'Sign-in with GitHub ends on $date. Your account, with everything in it, will be deleted then, unless you set up a password before.'; return 'Sign-in with GitHub ends on $date. Your account, with everything in it, will be deleted then, unless you set up a password before.';
} }
@override
String get passwordSignedInBefore =>
'Only a device signed in since then can dismiss this warning: sign in again on this one, or change your password.';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -464,6 +464,7 @@ PasswordProblem _problemOf(protocol.PasswordRefusal reason) => switch (reason) {
protocol.PasswordRefusal.sessionEnded => PasswordProblem.sessionEnded, protocol.PasswordRefusal.sessionEnded => PasswordProblem.sessionEnded,
protocol.PasswordRefusal.passwordChanged => PasswordProblem.passwordChanged, protocol.PasswordRefusal.passwordChanged => PasswordProblem.passwordChanged,
protocol.PasswordRefusal.signInAgain => PasswordProblem.signInAgain, protocol.PasswordRefusal.signInAgain => PasswordProblem.signInAgain,
protocol.PasswordRefusal.signedInBefore => PasswordProblem.signedInBefore,
}; };
/// Keeps one session in memory, for a client used once. /// Keeps one session in memory, for a client used once.

View file

@ -34,6 +34,10 @@ enum PasswordProblem() {
/// then go on. /// then go on.
signInAgain, signInAgain,
/// The warning that a session was ended is dismissed only by a session
/// made since: sign in again on this device, or change the password.
signedInBefore,
/// No reset waits for the address. /// No reset waits for the address.
noReset, noReset,

View file

@ -27,6 +27,7 @@ String passwordProblemText(BuildContext context, PasswordFailed failed) {
PasswordProblem.sessionEnded => l10n.passwordSessionEnded, PasswordProblem.sessionEnded => l10n.passwordSessionEnded,
PasswordProblem.passwordChanged => l10n.passwordChangedSignInAgain, PasswordProblem.passwordChanged => l10n.passwordChangedSignInAgain,
PasswordProblem.signInAgain => l10n.passwordSignInAgain, PasswordProblem.signInAgain => l10n.passwordSignInAgain,
PasswordProblem.signedInBefore => l10n.passwordSignedInBefore,
PasswordProblem.noReset => l10n.passwordNoReset, PasswordProblem.noReset => l10n.passwordNoReset,
PasswordProblem.passwordExists => l10n.passwordExists, PasswordProblem.passwordExists => l10n.passwordExists,
PasswordProblem.addressTaken => l10n.passwordAddressTaken, PasswordProblem.addressTaken => l10n.passwordAddressTaken,

View file

@ -93,7 +93,13 @@ enum PasswordRefusal implements _isc.SerializableModel {
/// A first password for an account that signs in with GitHub alone takes /// A first password for an account that signs in with GitHub alone takes
/// a GitHub sign-in of the last 10 minutes (the fourth reading: a session /// a GitHub sign-in of the last 10 minutes (the fourth reading: a session
/// alone sets none): sign in with GitHub again, then set it. /// alone sets none): sign in with GitHub again, then set it.
signInAgain; signInAgain,
/// The warning that a session of the account was ended for its wrong
/// proofs is dismissed only by a session made since (the full reading's
/// L1: another session of a stolen device would dismiss its only use):
/// sign in again on this device, or change the password.
signedInBefore;
static PasswordRefusal fromJson(String name) { static PasswordRefusal fromJson(String name) {
switch (name) { switch (name) {
@ -139,6 +145,8 @@ enum PasswordRefusal implements _isc.SerializableModel {
return PasswordRefusal.passwordChanged; return PasswordRefusal.passwordChanged;
case 'signInAgain': case 'signInAgain':
return PasswordRefusal.signInAgain; return PasswordRefusal.signInAgain;
case 'signedInBefore':
return PasswordRefusal.signedInBefore;
default: default:
throw ArgumentError( throw ArgumentError(
'Value "$name" cannot be converted to "PasswordRefusal"', 'Value "$name" cannot be converted to "PasswordRefusal"',

View file

@ -93,7 +93,13 @@ enum PasswordRefusal implements _is.SerializableModel {
/// A first password for an account that signs in with GitHub alone takes /// A first password for an account that signs in with GitHub alone takes
/// a GitHub sign-in of the last 10 minutes (the fourth reading: a session /// a GitHub sign-in of the last 10 minutes (the fourth reading: a session
/// alone sets none): sign in with GitHub again, then set it. /// alone sets none): sign in with GitHub again, then set it.
signInAgain; signInAgain,
/// The warning that a session of the account was ended for its wrong
/// proofs is dismissed only by a session made since (the full reading's
/// L1: another session of a stolen device would dismiss its only use):
/// sign in again on this device, or change the password.
signedInBefore;
static PasswordRefusal fromJson(String name) { static PasswordRefusal fromJson(String name) {
switch (name) { switch (name) {
@ -139,6 +145,8 @@ enum PasswordRefusal implements _is.SerializableModel {
return PasswordRefusal.passwordChanged; return PasswordRefusal.passwordChanged;
case 'signInAgain': case 'signInAgain':
return PasswordRefusal.signInAgain; return PasswordRefusal.signInAgain;
case 'signedInBefore':
return PasswordRefusal.signedInBefore;
default: default:
throw ArgumentError( throw ArgumentError(
'Value "$name" cannot be converted to "PasswordRefusal"', 'Value "$name" cannot be converted to "PasswordRefusal"',

View file

@ -184,13 +184,30 @@ Future<void> noteSessionEndedForProofs(
/// `AccountRegister.dismissSessionEndedWarning`. /// `AccountRegister.dismissSessionEndedWarning`.
Future<void> dismissSessionEndedWarning( Future<void> dismissSessionEndedWarning(
Session session, Session session,
UuidValue person, UuidValue person, {
) async { required DateTime? sessionMadeAt,
}) async {
// SELECT * FROM "account_password" WHERE "authUserId" = $1
final row = await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
);
final warned = row?.sessionEndedForProofsAt;
if (warned == null) {
return;
}
// Only a session made since (the full reading's L1): another session of
// the device whose session was ended would dismiss the warning's only
// use, its owner learning of it.
if (sessionMadeAt == null || !sessionMadeAt.isAfter(warned)) {
throw PasswordRefusedException(reason: PasswordRefusal.signedInBefore);
}
// UPDATE "account_password" SET "sessionEndedForProofsAt" = NULL // UPDATE "account_password" SET "sessionEndedForProofsAt" = NULL
// WHERE "authUserId" = $1 // WHERE "authUserId" = $1 AND "sessionEndedForProofsAt" = $2
await AccountPassword.db.updateWhere( await AccountPassword.db.updateWhere(
session, session,
columnValues: (t) => [t.sessionEndedForProofsAt(null)], columnValues: (t) => [t.sessionEndedForProofsAt(null)],
where: (t) => t.authUserId.equals(person), where: (t) =>
t.authUserId.equals(person) & t.sessionEndedForProofsAt.equals(warned),
); );
} }

View file

@ -161,12 +161,22 @@ class PasswordEndpoint extends Endpoint {
/// ten wrong proofs in a row (`account.signInWays`' /// ten wrong proofs in a row (`account.signInWays`'
/// `sessionEndedForProofsAt`). Their session alone does: it hides a /// `sessionEndedForProofsAt`). Their session alone does: it hides a
/// warning and changes nothing about how the account is secured. /// warning and changes nothing about how the account is secured.
Future<void> dismissSessionEndedWarning(Session session) => Future<void> dismissSessionEndedWarning(Session session) async {
AccountRegister.of(session.serverpod).dismissSessionEndedWarning( final authenticated = session.authenticated!;
// SELECT * FROM "serverpod_auth_core_session" WHERE "id" = $1
final asking = Uuid.isValidUUID(fromString: authenticated.authId)
? await ServerSideSession.db.findById(
session, session,
session.authenticated!.authUserId, UuidValue.withValidation(authenticated.authId),
)
: null;
await AccountRegister.of(session.serverpod).dismissSessionEndedWarning(
session,
authenticated.authUserId,
sessionMadeAt: asking?.createdAt,
); );
} }
}
/// Whether [session]'s own session (this server's) was made by a GitHub /// Whether [session]'s own session (this server's) was made by a GitHub
/// sign-in within [gitHubSignInFreshFor] (the fourth reading). /// sign-in within [gitHubSignInFreshFor] (the fourth reading).

View file

@ -62,3 +62,8 @@ values:
### a GitHub sign-in of the last 10 minutes (the fourth reading: a session ### a GitHub sign-in of the last 10 minutes (the fourth reading: a session
### alone sets none): sign in with GitHub again, then set it. ### alone sets none): sign in with GitHub again, then set it.
- signInAgain - signInAgain
### The warning that a session of the account was ended for its wrong
### proofs is dismissed only by a session made since (the full reading's
### L1: another session of a stolen device would dismiss its only use):
### sign in again on this device, or change the password.
- signedInBefore

View file

@ -143,7 +143,11 @@ abstract interface class AccountRegister {
Future<void> noteSessionEndedForProofs(Session session, UuidValue person); Future<void> noteSessionEndedForProofs(Session session, UuidValue person);
/// Dismisses [person]'s warning that a session was ended for its proofs. /// Dismisses [person]'s warning that a session was ended for its proofs.
Future<void> dismissSessionEndedWarning(Session session, UuidValue person); Future<void> dismissSessionEndedWarning(
Session session,
UuidValue person, {
required DateTime? sessionMadeAt,
});
/// Whether people sign in with a password and TOTP (ADR 0017): the /// Whether people sign in with a password and TOTP (ADR 0017): the
/// register holds the keys it needs. /// register holds the keys it needs.

View file

@ -205,6 +205,8 @@ Future<Map<String, Object?>?> _answer(
await register.dismissSessionEndedWarning( await register.dismissSessionEndedWarning(
session, session,
arguments.id('person'), arguments.id('person'),
// When the member's session asking was made (the full reading's L1).
sessionMadeAt: arguments.optionalTime('sessionMadeAt'),
); );
return const {}; return const {};
case RegisterOperation.deleteAccount: case RegisterOperation.deleteAccount:
@ -521,6 +523,12 @@ extension type const _Arguments(Map<String, Object?> _map) {
ByteData? optionalBytes(String name) => ByteData? optionalBytes(String name) =>
optionalString(name) == null ? null : bytes(name); optionalString(name) == null ? null : bytes(name);
/// The moment [name] (ISO 8601) in UTC; null where it is missing.
DateTime? optionalTime(String name) => switch (optionalString(name)) {
final value? => DateTime.parse(value).toUtc(),
null => null,
};
/// Whether the argument [name] holds anything (not null). /// Whether the argument [name] holds anything (not null).
bool has(String name) => _map[name] != null; bool has(String name) => _map[name] != null;

View file

@ -408,8 +408,13 @@ final class LocalRegister implements AccountRegister {
@override @override
Future<void> dismissSessionEndedWarning( Future<void> dismissSessionEndedWarning(
Session session, Session session,
UuidValue person, UuidValue person, {
) => owner_proofs.dismissSessionEndedWarning(session, person); required DateTime? sessionMadeAt,
}) => owner_proofs.dismissSessionEndedWarning(
session,
person,
sessionMadeAt: sessionMadeAt,
);
@override @override
Future<bool> passwordSignInOffered(Session session) async => Future<bool> passwordSignInOffered(Session session) async =>

View file

@ -285,9 +285,11 @@ final class RemoteRegister implements AccountRegister {
@override @override
Future<void> dismissSessionEndedWarning( Future<void> dismissSessionEndedWarning(
Session session, Session session,
UuidValue person, UuidValue person, {
) => _ask(RegisterOperation.dismissSessionEndedWarning, { required DateTime? sessionMadeAt,
}) => _ask(RegisterOperation.dismissSessionEndedWarning, {
'person': person.uuid, 'person': person.uuid,
'sessionMadeAt': sessionMadeAt?.toUtc().toIso8601String(),
}); });
@override @override

View file

@ -459,6 +459,8 @@ void main() {
test('which her sign-in ways and her next sign-in tell, with when, ' test('which her sign-in ways and her next sign-in tell, with when, '
'until she dismisses it', () async { 'until she dismisses it', () async {
expect(await warningOn(onLaptop()), isNull); expect(await warningOn(onLaptop()), isNull);
// Her tablet, signed in before (at the tests' time).
final tablet = await atNow(() => signInNewDevice(session, anna));
final ended = await endPhone(); final ended = await endPhone();
expect(await warningOn(onLaptop()), ended); expect(await warningOn(onLaptop()), ended);
@ -467,7 +469,34 @@ void main() {
// Told, not dismissed: it stays until she says so. // Told, not dismissed: it stays until she says so.
expect(await warningOn(onLaptop()), ended); expect(await warningOn(onLaptop()), ended);
await endpoints.password.dismissSessionEndedWarning(onLaptop()); // Her tablet's session is older than the warning: it dismisses
// nothing (the full reading's L1: another session of a stolen
// device would dismiss it). Signed in again, it does.
await expectLater(
endpoints.password.dismissSessionEndedWarning(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{},
authId: tablet.uuid,
),
),
),
refused(PasswordRefusal.signedInBefore),
);
expect(await warningOn(onLaptop()), ended);
final newLaptop = await atNow(
() => signInNewDevice(session, anna),
);
await endpoints.password.dismissSessionEndedWarning(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{},
authId: newLaptop.uuid,
),
),
);
expect(await warningOn(onLaptop()), isNull); expect(await warningOn(onLaptop()), isNull);
nextStep(); nextStep();
expect((await signIn()).sessionEndedForProofsAt, isNull); expect((await signIn()).sessionEndedForProofsAt, isNull);

View file

@ -48,6 +48,9 @@ final class _Register implements AccountRegister {
/// member's made just now. /// member's made just now.
final freshGitHubSignIns = <UuidValue>{}; final freshGitHubSignIns = <UuidValue>{};
/// When the session dismissing a warning was made, by person.
final dismissedMadeAt = <UuidValue, DateTime?>{};
/// Those [setPassword] was told are anonymous on the member. /// Those [setPassword] was told are anonymous on the member.
final anonymousHere_ = <UuidValue>{}; final anonymousHere_ = <UuidValue>{};
@ -226,9 +229,13 @@ final class _Register implements AccountRegister {
} }
@override @override
Future<void> dismissSessionEndedWarning(Session session, UuidValue person) => Future<void> dismissSessionEndedWarning(
_answer('dismissSessionEndedWarning', () { Session session,
UuidValue person, {
required DateTime? sessionMadeAt,
}) => _answer('dismissSessionEndedWarning', () {
dismissed.add(person); dismissed.add(person);
dismissedMadeAt[person] = sessionMadeAt;
}); });
@override @override
@ -1337,6 +1344,8 @@ void main() {
"never another's", () async { "never another's", () async {
await endpoints.password.dismissSessionEndedWarning(asBen()); await endpoints.password.dismissSessionEndedWarning(asBen());
expect(register.dismissed, {ben}); expect(register.dismissed, {ben});
// With when Ben's session here was made (the full reading's L1).
expect(register.dismissedMadeAt[ben], isNotNull);
await expectLater( await expectLater(
endpoints.password.dismissSessionEndedWarning( endpoints.password.dismissSessionEndedWarning(
sessionBuilder.copyWith( sessionBuilder.copyWith(

View file

@ -613,7 +613,21 @@ void main() {
expect(answer.verifiesServer(proof.serverProof), isTrue); expect(answer.verifiesServer(proof.serverProof), isTrue);
expect(proof.passwordKeys.toJson(), device.passwordKeys.toJson()); expect(proof.passwordKeys.toJson(), device.passwordKeys.toJson());
expect(proof.sessionEndedForProofsAt, warned); expect(proof.sessionEndedForProofsAt, warned);
await remote.dismissSessionEndedWarning(session, annaOnTodo); // An older session of the member's dismisses nothing (the full
// reading's L1); one made since does.
await expectLater(
remote.dismissSessionEndedWarning(
session,
annaOnTodo,
sessionMadeAt: warned,
),
refused(PasswordRefusal.signedInBefore),
);
await remote.dismissSessionEndedWarning(
session,
annaOnTodo,
sessionMadeAt: warned.add(const Duration(seconds: 1)),
);
expect( expect(
(await remote.signInWaysOf( (await remote.signInWaysOf(
session, session,