fix(auth): an account with a password deletes itself only with it and a code

A session alone deleted any account, with everything encrypted under its
keys (the second reading's lows; decided by the developer: the password
and a code, as a change of password takes). account.delete now takes a
DeletionProof (the password's SRP proof for a beginChange challenge, a
TOTP or recovery code, the host the device addressed); the register
checks it (requireOwnerToDelete), a member passing it on bound to its own
host. Accounts without a password, anonymous ones among them, delete with
their session as before. In the app, deleting an account with a password
asks for it and a code in a sheet of its own; a wrong one deletes
nothing.

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-20 21:59:42 +02:00
commit d53844bc93
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 873 additions and 32 deletions

View file

@ -209,5 +209,6 @@
"passwordFailed": "Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal.",
"passwordMinLength": "Mindestens {count} Zeichen",
"passwordTooShort": "Ein neues Passwort braucht mindestens {count} Zeichen: Sollte die Datenbank der Instanz je nach außen gelangen, wird ein kurzes zuerst erraten.",
"passwordCodesWait": "Zu viele falsche Codes hintereinander: Bis {date} wird kein Code angenommen. Waren es nicht deine, kennt jemand dein Passwort: Ändere es dann."
"passwordCodesWait": "Zu viele falsche Codes hintereinander: Bis {date} wird kein Code angenommen. Waren es nicht deine, kennt jemand dein Passwort: Ändere es dann.",
"passwordDeleteIntro": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Gib zur Bestätigung dein Passwort und einen Code deiner Authenticator-App ein."
}

View file

@ -456,5 +456,6 @@
"type": "String"
}
}
}
},
"passwordDeleteIntro": "Your account goes with everything in it, in every service of this instance. To confirm, type your password and a code from your authenticator app."
}

View file

@ -1362,6 +1362,12 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to:
/// **'Too many wrong codes in a row: no code is taken until {date}. If they weren\'t yours, someone knows your password: change it then.'**
String passwordCodesWait(String date);
/// No description provided for @passwordDeleteIntro.
///
/// In en, this message translates to:
/// **'Your account goes with everything in it, in every service of this instance. To confirm, type your password and a code from your authenticator app.'**
String get passwordDeleteIntro;
}
class _HalkyonAppLocalizationsDelegate

View file

@ -788,4 +788,8 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String passwordCodesWait(String date) {
return 'Zu viele falsche Codes hintereinander: Bis $date wird kein Code angenommen. Waren es nicht deine, kennt jemand dein Passwort: Ändere es dann.';
}
@override
String get passwordDeleteIntro =>
'Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Gib zur Bestätigung dein Passwort und einen Code deiner Authenticator-App ein.';
}

View file

@ -771,6 +771,10 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
String passwordCodesWait(String date) {
return 'Too many wrong codes in a row: no code is taken until $date. If they weren\'t yours, someone knows your password: change it then.';
}
@override
String get passwordDeleteIntro =>
'Your account goes with everything in it, in every service of this instance. To confirm, type your password and a code from your authenticator app.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -4,6 +4,8 @@ import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/auth/bloc/session_bloc.dart';
import 'package:halkyon_app/src/instance/instance_scope.dart';
import 'package:halkyon_app/src/instance/widget/account_switcher_sheet.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_app/src/password/widget/delete_account_sheet.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -104,17 +106,37 @@ class const AccountTiles({
session.add(const SessionEventSignOutRequested());
}
/// Deletes the account with everything in it, after asking.
/// Deletes the account with everything in it, after asking; an account
/// with a password only with it and a code (ADR 0017), which a sheet asks
/// for.
Future<void> _delete(BuildContext context) async {
final l10n = HalkyonAppLocalizations.of(context);
final session = context.read<SessionBloc>();
if (await confirmDestructiveAction(
final passwords = AppScope.of(context).passwords;
final identified = switch (session.state) {
SessionStateActive(:final person) => person.isIdentified,
_ => false,
};
if (!await confirmDestructiveAction(
context,
title: l10n.accountDeleteTitle,
message: deleteMessage,
confirmLabel: l10n.accountDelete,
)) {
return;
}
final bool hasPassword;
try {
hasPassword = identified && (await passwords.security()).hasPassword;
} on PasswordFailed {
// Unreachable: deleting would fail too, as the session bloc says.
session.add(const SessionEventDeleteRequested());
return;
}
if (!hasPassword) {
session.add(const SessionEventDeleteRequested());
} else if (context.mounted) {
await showDeleteAccountWithPassword(context);
}
}
}

View file

@ -105,6 +105,16 @@ abstract interface class PasswordGateway() {
/// A change of password's first step.
Future<protocol.PasswordChallenge> beginChange();
/// Deletes the signed-in person's account, which has a password: [answer]
/// to [challenge] ([beginChange]) proves it, with a [totpCode] or
/// [recoveryCode]; this device then forgets its session.
Future<void> deleteAccount(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
});
/// Changes the password: [answer] to [challenge] proves the old one.
Future<void> change(
protocol.PasswordChallenge challenge,

View file

@ -344,6 +344,29 @@ class PasswordRepository(
_changed();
}
/// Deletes the signed-in person's account, which has a password, with
/// [password] and a [totpCode] or [recoveryCode], as a change of password
/// takes (a session alone deletes only an account without one): on the
/// instance and every service of it, then signed out here.
Future<void> deleteAccount({
required String password,
String? totpCode,
String? recoveryCode,
}) async {
final challenge = await _gateway.beginChange();
final unlock = await _crypto.unlock(
password,
salt: Uint8List.sublistView(challenge.salt),
costs: challenge.costs,
);
await _gateway.deleteAccount(
challenge,
await _crypto.answer(unlock, challenge, host: _gateway.host),
totpCode: totpCode?.replaceAll(' ', ''),
recoveryCode: recoveryCode,
);
}
/// A new recovery phrase for the signed-in person's keys, to show; the
/// instance takes it with [keepPhrase].
Future<NewPhrase> newPhrase() async {

View file

@ -179,6 +179,28 @@ class ServerpodPasswordGateway(
Future<protocol.PasswordChallenge> beginChange() =>
_failing(_base.password.beginChange);
@override
Future<void> deleteAccount(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
}) => _failing(() async {
await _base.account.delete(
proof: protocol.DeletionProof(
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
host: host,
),
);
// The server ended every session of the person; forget this device's
// copy, which signs out here too.
await _client.auth.updateSignedInUser(null);
});
@override
Future<void> change(
protocol.PasswordChallenge challenge,

View file

@ -0,0 +1,95 @@
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_security_cubit.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';
import 'package:material_ui/material_ui.dart';
/// Deletes the signed-in person's account, which has a password: the
/// password and a code from an authenticator, as changing the password
/// takes (ADR 0017; the second reading). Once deleted, the person is signed
/// out here, and the app asks how to start.
Future<void> showDeleteAccountWithPassword(BuildContext context) =>
showAdaptiveSheet<void>(
context,
swipeable: false,
builder: (context) => const _DeleteAccount(),
);
class const _DeleteAccount() extends StatefulWidget {
@override
State<_DeleteAccount> createState() => _DeleteAccountState();
}
class _DeleteAccountState() extends State<_DeleteAccount> {
final _password = TextEditingController();
final _code = TextEditingController();
late final _step = SecurityStepCubit<bool>();
@override
void dispose() {
_password.dispose();
_code.dispose();
_step.close();
super.dispose();
}
Future<void> _submit() async {
final passwords = AppScope.of(context).passwords;
await _step.run(() async {
await passwords.deleteAccount(
password: _password.text,
totpCode: _code.text,
);
return true;
});
}
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final colors = Theme.of(context).colorScheme;
return BlocBuilder<SecurityStepCubit<bool>, SecurityStepState<bool>>(
bloc: _step,
builder: (context, state) => SheetScaffold(
title: l10n.accountDeleteTitle,
busy: state.busy,
body: AutofillGroup(
child: state.busy
? const SheetWorking()
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SheetIntro(l10n.passwordDeleteIntro),
PasswordField(
controller: _password,
label: l10n.passwordCurrent,
autofocus: true,
),
const SizedBox(height: 8),
CodeField(
controller: _code,
kind: CodeKind.totp,
errorText: switch (state.problem) {
final failed? => passwordProblemText(context, failed),
null => null,
},
onSubmitted: (_) => _submit(),
),
],
),
),
footer: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: colors.error,
foregroundColor: colors.onError,
),
onPressed: state.busy ? null : _submit,
child: Text(l10n.accountDelete),
),
),
);
}
}

View file

@ -512,6 +512,21 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
return _challengeFor(account.address);
});
@override
Future<void> deleteAccount(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
}) async {
final account = await _call(
'deleteAccount',
() => _ownerProved(challenge, answer, totpCode, recoveryCode),
);
accounts.remove(account.address);
await _auth.deleteAccount();
}
@override
Future<void> change(
protocol.PasswordChallenge challenge,

View file

@ -173,6 +173,54 @@ void main() {
expect(find.text('Signed out'), findsOneWidget);
});
testWidgets('deleting an account with a password asks for it and a '
'code, which a wrong one fails, deleting nothing', (tester) async {
// The first person the fake password gateway makes.
const person = '00000000-0000-4000-8000-000000000001';
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: person, isIdentified: true),
);
await start(tester);
dependencies.passwordGateway.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: dependencies.crypto,
);
Finder field(String label) => find.widgetWithText(TextField, label);
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 0);
expect(field('Current password'), findsOneWidget);
await tester.enterText(field('Current password'), 'wrong horse');
await tester.enterText(
field('Code from your authenticator app'),
fakeTotpCode,
);
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(
find.text("The address, the password or the code isn't right."),
findsOneWidget,
);
expect(gateway.accountDeletions, 0);
expect(dependencies.passwordGateway.accounts, contains('anna@example.org'));
await tester.enterText(field('Current password'), 'correct horse');
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 1);
expect(
dependencies.passwordGateway.accounts,
isNot(contains('anna@example.org')),
);
expect(find.text('Signed out'), findsOneWidget);
});
testWidgets('speaks German', (tester) async {
await start(tester);
final german = lookupHalkyonAppLocalizations(const Locale('de'));

View file

@ -302,6 +302,28 @@ void main() {
expect(anna.password, 'correct horse');
});
test('deletes the account with the password and a code alone, then '
'signed out here; a wrong one or none deletes nothing', () async {
await expectLater(
passwords.deleteAccount(password: 'wrong', totpCode: fakeTotpCode),
failsWith(PasswordProblem.wrongCredentials),
);
await expectLater(
passwords.deleteAccount(password: 'correct horse'),
failsWith(PasswordProblem.wrongCredentials),
);
expect(gateway.accounts, contains('anna@example.org'));
expect(auth.status, isA<SignedIn>());
await passwords.deleteAccount(
password: 'correct horse',
totpCode: fakeTotpCode,
);
expect(gateway.accounts, isNot(contains('anna@example.org')));
expect(auth.status, const SignedOut());
});
test('makes a new recovery phrase, which the instance takes for the '
'password and a code alone', () async {
final phrase = await passwords.newPhrase();