fix(auth)!: a first password takes a fresh proof of the account's way in

The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).

- An account with an address proves it anew: a code mailed to that
  address for this (EmailCodePurpose.firstPassword, its own mail), never
  the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
  a GitHub sign-in (the session's method and creation time, where the
  session lives; members tell the register), refused signInAgain before
  any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
  they add.

The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.

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 11:31:22 +02:00
commit f0413531ff
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 762 additions and 66 deletions

View file

@ -215,5 +215,10 @@
"passwordAddressUnknown": "Gib die E-Mail-Adresse deines Kontos ein: Dorthin geht der Code.",
"passwordDeleteIntroByMail": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Keiner seiner Authenticators funktioniert gerade, daher bestätigt es ein Code per E-Mail: Gib dein Passwort ein, dann den Code, den wir an deine Adresse schicken.",
"passwordDeleteSendCode": "Code per E-Mail schicken",
"passwordChangedSignInAgain": "Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an."
"passwordChangedSignInAgain": "Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an.",
"passwordSignInAgain": "Um einem Konto, das sich mit GitHub anmeldet, ein Passwort zu geben, melde dich erst wieder mit GitHub an: Es ist mehr als ein paar Minuten her.",
"passwordSignInWithGitHubAgain": "Wieder mit GitHub anmelden",
"passwordOfferTitle": "Richte ein Passwort ein",
"passwordOfferBody": "Die Anmeldung mit GitHub endet. Richte ein Passwort und eine Authenticator-App ein, um dein Konto zu behalten.",
"passwordOfferLater": "Später"
}

View file

@ -462,5 +462,10 @@
"passwordAddressUnknown": "Enter your account's email address: the code goes there.",
"passwordDeleteIntroByMail": "Your account goes with everything in it, in every service of this instance. None of its authenticators works now, so a code by mail confirms it: type your password, then the code we email to your address.",
"passwordDeleteSendCode": "Email me a code",
"passwordChangedSignInAgain": "Your password was changed, but this service couldn't sign you in just yet. Sign in again with the new password."
"passwordChangedSignInAgain": "Your password was changed, but this service couldn't sign you in just yet. Sign in again with the new password.",
"passwordSignInAgain": "To give an account that signs in with GitHub its password, sign in with GitHub again first: it has been more than a few minutes.",
"passwordSignInWithGitHubAgain": "Sign in with GitHub again",
"passwordOfferTitle": "Set up a password",
"passwordOfferBody": "Sign-in with GitHub is ending. Set up a password and an authenticator app to keep your account.",
"passwordOfferLater": "Later"
}

View file

@ -1398,6 +1398,36 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to:
/// **'Your password was changed, but this service couldn\'t sign you in just yet. Sign in again with the new password.'**
String get passwordChangedSignInAgain;
/// No description provided for @passwordSignInAgain.
///
/// In en, this message translates to:
/// **'To give an account that signs in with GitHub its password, sign in with GitHub again first: it has been more than a few minutes.'**
String get passwordSignInAgain;
/// No description provided for @passwordSignInWithGitHubAgain.
///
/// In en, this message translates to:
/// **'Sign in with GitHub again'**
String get passwordSignInWithGitHubAgain;
/// No description provided for @passwordOfferTitle.
///
/// In en, this message translates to:
/// **'Set up a password'**
String get passwordOfferTitle;
/// No description provided for @passwordOfferBody.
///
/// In en, this message translates to:
/// **'Sign-in with GitHub is ending. Set up a password and an authenticator app to keep your account.'**
String get passwordOfferBody;
/// No description provided for @passwordOfferLater.
///
/// In en, this message translates to:
/// **'Later'**
String get passwordOfferLater;
}
class _HalkyonAppLocalizationsDelegate

View file

@ -811,4 +811,21 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get passwordChangedSignInAgain =>
'Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an.';
@override
String get passwordSignInAgain =>
'Um einem Konto, das sich mit GitHub anmeldet, ein Passwort zu geben, melde dich erst wieder mit GitHub an: Es ist mehr als ein paar Minuten her.';
@override
String get passwordSignInWithGitHubAgain => 'Wieder mit GitHub anmelden';
@override
String get passwordOfferTitle => 'Richte ein Passwort ein';
@override
String get passwordOfferBody =>
'Die Anmeldung mit GitHub endet. Richte ein Passwort und eine Authenticator-App ein, um dein Konto zu behalten.';
@override
String get passwordOfferLater => 'Später';
}

View file

@ -794,6 +794,23 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get passwordChangedSignInAgain =>
'Your password was changed, but this service couldn\'t sign you in just yet. Sign in again with the new password.';
@override
String get passwordSignInAgain =>
'To give an account that signs in with GitHub its password, sign in with GitHub again first: it has been more than a few minutes.';
@override
String get passwordSignInWithGitHubAgain => 'Sign in with GitHub again';
@override
String get passwordOfferTitle => 'Set up a password';
@override
String get passwordOfferBody =>
'Sign-in with GitHub is ending. Set up a password and an authenticator app to keep your account.';
@override
String get passwordOfferLater => 'Later';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -19,6 +19,9 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
on<SessionEventSignInRequested>(_onSignInRequested);
on<SessionEventStartedAnonymously>(_onStartedAnonymously);
on<SessionEventGitHubRequested>(_onGitHubRequested);
on<SessionEventPasswordOfferDone>(
(event, emit) => emit(_stateOf(_auth.status)),
);
on<SessionEventSignedOut>(
(event, emit) => emit(SessionStateSignedOut(problem: event.problem)),
);
@ -84,12 +87,19 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
Emitter<SessionState> emit,
) async {
emit(const SessionStateSignedOut(busy: true));
// The person signed in with GitHub is offered a password first (ADR
// 0017, D2: "at their next sign-in"), while that sign-in is recent
// enough for the instance to take one (the fourth reading).
_offerPassword = true;
try {
if (!await _auth.switchToGitHub()) {
_offerPassword = false;
emit(const SessionStateSignedOut());
}
// Signed in: the status change shows the person's app.
// Signed in: the status change shows the offer, then the person's
// app.
} on GitHubSignInFailed catch (e) {
_offerPassword = false;
emit(
SessionStateSignedOut(
problem: e.problem == GitHubSignInProblem.unknownAccount
@ -98,11 +108,16 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
),
);
} on Object catch (error, st) {
_offerPassword = false;
addError(error, st);
emit(const SessionStateSignedOut(problem: SignedOutProblem.failed));
}
}
/// Whether the next person signed in comes from a GitHub sign-in, and is
/// offered a password before their app ([SessionStateGitHubSignedIn]).
bool _offerPassword = false;
Future<void> _onSignOutRequested(
SessionEventSignOutRequested event,
Emitter<SessionState> emit,
@ -132,9 +147,15 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
}
SessionState _stateOf(AuthStatus status) => switch (status) {
SignedIn() when _offerPassword => _offered(status),
SignedIn() => SessionStateActive(status),
SignedOut() => const SessionStateSignedOut(),
};
SessionState _offered(SignedIn person) {
_offerPassword = false;
return SessionStateGitHubSignedIn(person);
}
}
/// What happens to the session.
@ -163,6 +184,10 @@ final class const SessionEventSignedOut({final SignedOutProblem? problem})
List<Object?> get props => [problem];
}
/// The password offered after a GitHub sign-in was set up, put off, or not
/// needed: on to the person's app.
final class const SessionEventPasswordOfferDone() extends SessionEvent;
/// The person confirmed that they want to sign out.
final class const SessionEventSignOutRequested() extends SessionEvent;
@ -195,6 +220,17 @@ final class const SessionStateActive(
List<Object?> get props => [person];
}
/// [person] signed in with GitHub just now: before their app, a password
/// is offered where the instance offers one and they have none (ADR 0017,
/// D2), while the sign-in is recent enough to set it.
final class const SessionStateGitHubSignedIn(
/// Who signed in.
final SignedIn person,
) extends SessionState {
@override
List<Object?> get props => [person];
}
/// Nobody is signed in since signing out: the app asks how to start.
final class const SessionStateSignedOut({
/// Whether a way to start is under way.

View file

@ -5,6 +5,12 @@ import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/auth/bloc/session_bloc.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/widget/start_screen.dart';
import 'package:halkyon_app/src/instance/instance_scope.dart';
import 'package:halkyon_app/src/password/bloc/account_setup_cubit.dart';
import 'package:halkyon_app/src/password/model/account_security.dart';
import 'package:halkyon_app/src/password/widget/account_setup_sheet.dart';
import 'package:halkyon_app/src/password/widget/password_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// Shows the app of whoever is signed in, signing in anonymously at a
@ -46,6 +52,10 @@ class const SessionGate({
look: look(context),
home: StartScreen(state: signedOut),
),
SessionStateGitHubSignedIn() => PlainApp(
look: look(context),
home: const _PasswordOffer(),
),
SessionStateActive(:final person) => KeyedSubtree(
key: ValueKey(person.authUserId),
child: app(person),
@ -78,3 +88,106 @@ class const _SignInFailed() extends StatelessWidget {
);
}
}
/// After a GitHub sign-in, a password for an account that has none, where
/// the instance offers them (ADR 0017, D2: GitHub sign-in ends), while the
/// sign-in is recent enough for the instance to take one; else, or once
/// set up or put off, on to the person's app.
class const _PasswordOffer() extends StatefulWidget {
@override
State<_PasswordOffer> createState() => _PasswordOfferState();
}
class _PasswordOfferState() extends State<_PasswordOffer> {
late final Future<AccountSecurity?> _security =
AppScope.of(
context,
).passwords.security().then<AccountSecurity?>(
(security) => security,
onError: (Object _) => null,
);
void _done() =>
context.read<SessionBloc>().add(const SessionEventPasswordOfferDone());
Future<void> _setUp(AccountSecurity security) async {
final address = security.hasAddress
? await AppScope.of(context).auth.emailAddress()
: null;
if (!mounted) {
return;
}
await showAccountSetupSheet(
context,
mode: AccountSetupMode.setPassword,
instance:
InstanceScope.maybeOf(context)?.choice.current?.name ??
AppScope.of(context).serverName,
hasAddress: security.hasAddress,
address: address,
);
if (mounted) {
_done();
}
}
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
return FutureBuilder<AccountSecurity?>(
future: _security,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Scaffold(body: Center(child: AppLoadingIndicator()));
}
final security = snapshot.data;
if (security == null || !security.offered || security.hasPassword) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_done();
}
});
return const Scaffold(body: Center(child: AppLoadingIndicator()));
}
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.passwordOfferTitle,
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 12),
Text(switch (security.gitHubEnds) {
final ends? => l10n.passwordSectionGitHubEnds(
passwordMoment(context, ends),
),
null => l10n.passwordOfferBody,
}),
const SizedBox(height: 24),
FilledButton(
onPressed: () => _setUp(security),
child: Text(l10n.passwordSectionSetUp),
),
const SizedBox(height: 8),
TextButton(
onPressed: _done,
child: Text(l10n.passwordOfferLater),
),
],
),
),
),
),
);
},
);
}
}

View file

@ -14,7 +14,9 @@ enum AccountSetupMode() {
signUp,
/// A password for the signed-in person (anonymous, or signing in with
/// GitHub or codes by mail): they keep everything.
/// GitHub or codes by mail): they keep everything. A session alone sets
/// none: an address of theirs takes a code mailed to it, GitHub a recent
/// sign-in (the fourth reading).
setPassword,
/// The reset of a whole account, once due: a new password, and new keys
@ -123,15 +125,16 @@ class AccountSetupCubit(
/// person's own); null where the setup asks for it.
final String? address,
/// Whether the signed-in person has an address already, so no code is
/// needed ([AccountSetupMode.setPassword]).
/// Whether the signed-in person has an address already, which the code
/// then goes to as proof that they are its owner
/// ([AccountSetupMode.setPassword], [MailCodePurpose.firstPassword]).
final bool _hasAddress = false,
final String? _phrase,
}) extends Cubit<AccountSetupState> {
this
: super(
AccountSetupState(
step: address == null && !_hasAddress
step: address == null
? AccountSetupStep.address
: AccountSetupStep.password,
address: address ?? '',
@ -221,13 +224,9 @@ class AccountSetupCubit(
await _run(_toCode);
}
/// The code mailed to the address, where one is needed; else the setup
/// goes to the instance at once.
/// The code mailed to the address: nothing goes to the instance without
/// it.
Future<void> _toCode() async {
if (_mode == AccountSetupMode.setPassword && _hasAddress) {
await _submit();
return;
}
_request = await _passwords.sendCode(
state.address,
language: _language,
@ -237,9 +236,12 @@ class AccountSetupCubit(
}
/// What the codes mailed here are for.
MailCodePurpose get _codePurpose => _mode == AccountSetupMode.finishReset
? MailCodePurpose.accountReset
: MailCodePurpose.signUp;
MailCodePurpose get _codePurpose => switch (_mode) {
AccountSetupMode.finishReset => MailCodePurpose.accountReset,
AccountSetupMode.setPassword when _hasAddress =>
MailCodePurpose.firstPassword,
_ => MailCodePurpose.signUp,
};
/// Mails a new code.
Future<void> mailCodeResent() => _run(() async {
@ -273,7 +275,7 @@ class AccountSetupCubit(
case AccountSetupMode.setPassword:
await _passwords.setPassword(
material,
address: _hasAddress ? null : state.address,
address: state.address,
request: _request,
code: code,
);

View file

@ -55,6 +55,8 @@ class ServerpodPasswordGateway(
protocol.EmailCodePurpose.authenticatorReset,
MailCodePurpose.accountReset => protocol.EmailCodePurpose.accountReset,
MailCodePurpose.deletion => protocol.EmailCodePurpose.deletion,
MailCodePurpose.firstPassword =>
protocol.EmailCodePurpose.firstPassword,
},
)).uuid,
);
@ -449,6 +451,7 @@ PasswordProblem _problemOf(protocol.PasswordRefusal reason) => switch (reason) {
protocol.PasswordRefusal.codesWait => PasswordProblem.codesWait,
protocol.PasswordRefusal.sessionEnded => PasswordProblem.sessionEnded,
protocol.PasswordRefusal.passwordChanged => PasswordProblem.passwordChanged,
protocol.PasswordRefusal.signInAgain => PasswordProblem.signInAgain,
};
/// Keeps one session in memory, for a client used once.

View file

@ -17,4 +17,8 @@ enum MailCodePurpose() {
/// Deleting an account whose setup never finished.
deletion,
/// Proving, signed in, the account's own address to give it its first
/// password: a session alone sets none.
firstPassword,
}

View file

@ -29,6 +29,11 @@ enum PasswordProblem() {
/// session for it yet: sign in again with the new password.
passwordChanged,
/// An account that signs in with GitHub alone takes its first password
/// only within minutes of a GitHub sign-in: sign in with GitHub again,
/// then go on.
signInAgain,
/// No reset waits for the address.
noReset,

View file

@ -152,10 +152,10 @@ class const _AccountSecurity() extends StatelessWidget {
final instance =
InstanceScope.maybeOf(context)?.choice.current?.name ??
AppScope.of(context).serverName;
// The address this device knows the person by, to name the account in
// their authenticator app.
// The address this device knows the person by, which the code goes to;
// the setup asks for it where the device doesn't know it.
final address = security.hasAddress
? await AppScope.of(context).auth.emailAddress() ?? ''
? await AppScope.of(context).auth.emailAddress()
: null;
if (!context.mounted) {
return;

View file

@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/password/bloc/account_setup_cubit.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_app/src/password/widget/password_texts.dart';
import 'package:halkyon_app/src/password/widget/password_widgets.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -9,8 +10,9 @@ import 'package:material_ui/material_ui.dart';
/// Sets up an account's password in a sheet (ADR 0017), as [mode] says: a
/// new account on [instance] from a device signed out, a password for the
/// signed-in person ([hasAddress]: whether they have an address, which
/// then needs no code), or the reset of the account at [address] once due
/// signed-in person ([hasAddress]: whether they have an address, which the
/// code then goes to; GitHub's people sign in with GitHub again should the
/// instance ask), or the reset of the account at [address] once due
/// ([phrase]: the recovery phrase that keeps its keys, else new ones).
/// True once done; the sheet can't be swiped away, as an account half set
/// up would otherwise be left behind unnoticed.
@ -76,6 +78,15 @@ class _AccountSetupState() extends State<_AccountSetup> {
AccountSetupCubit get _cubit => context.read<AccountSetupCubit>();
/// A GitHub sign-in made anew, then the code again (the instance takes a
/// first password for GitHub's people only within minutes of one).
Future<void> _signInWithGitHubAgain() async {
final cubit = _cubit;
if (await AppScope.of(context).auth.switchToGitHub() && mounted) {
await cubit.mailCodeEntered(_code.text);
}
}
void _submit(AccountSetupState state) {
switch (state.step) {
case AccountSetupStep.address:
@ -259,6 +270,14 @@ class _AccountSetupState() extends State<_AccountSetup> {
child: Text(l10n.passwordResendCode),
),
),
if (state.problem?.problem == PasswordProblem.signInAgain)
Align(
alignment: AlignmentDirectional.centerStart,
child: FilledButton.tonal(
onPressed: enabled ? _signInWithGitHubAgain : null,
child: Text(l10n.passwordSignInWithGitHubAgain),
),
),
],
),
AccountSetupStep.authenticator => AuthenticatorSetupView(

View file

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

View file

@ -76,6 +76,12 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
/// here.
final gitHubPeople = <String>{};
/// The people of [gitHubPeople] whose GitHub sign-in on this device is
/// too old for a first password (the fourth reading): refused
/// [PasswordProblem.signInAgain] until they are taken out, as signing in
/// with GitHub anew does in a test.
final staleGitHubSignIns = <String>{};
/// The codes mailed, in order.
final mailed =
<
@ -396,12 +402,28 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
}) => _call('setPassword', () {
final person = _signedIn();
var account = _accountOfPerson(person);
if (request != null && code != null) {
final address = _proven(request, code, MailCodePurpose.signUp);
if (accounts[address] case final other? when other.person != person) {
throw const PasswordFailed(PasswordProblem.addressTaken);
if (account != null) {
// A session alone sets none: their own address proves them (the
// fourth reading).
if (request == null || code == null) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
final address = _proven(request, code, MailCodePurpose.firstPassword);
if (address != account.address) {
throw const PasswordFailed(PasswordProblem.otherAddressLinked);
}
} else {
if (gitHubPeople.contains(person) &&
staleGitHubSignIns.contains(person)) {
throw const PasswordFailed(PasswordProblem.signInAgain);
}
if (request != null && code != null) {
final address = _proven(request, code, MailCodePurpose.signUp);
if (accounts[address] case final other? when other.person != person) {
throw const PasswordFailed(PasswordProblem.addressTaken);
}
account = accounts[address] = FakePasswordAccount(person, address);
}
account ??= accounts[address] = FakePasswordAccount(person, address);
}
if (account == null) {
throw const PasswordFailed(PasswordProblem.noAddress);