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
30 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.", "passwordFailed": "Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal.",
"passwordMinLength": "Mindestens {count} Zeichen", "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.", "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" "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: /// 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.'** /// **'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); 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 class _HalkyonAppLocalizationsDelegate

View file

@ -788,4 +788,8 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String passwordCodesWait(String date) { 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.'; 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) { 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.'; 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`). /// 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/auth/bloc/session_bloc.dart';
import 'package:halkyon_app/src/instance/instance_scope.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/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:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -104,17 +106,37 @@ class const AccountTiles({
session.add(const SessionEventSignOutRequested()); 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 { Future<void> _delete(BuildContext context) async {
final l10n = HalkyonAppLocalizations.of(context); final l10n = HalkyonAppLocalizations.of(context);
final session = context.read<SessionBloc>(); 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, context,
title: l10n.accountDeleteTitle, title: l10n.accountDeleteTitle,
message: deleteMessage, message: deleteMessage,
confirmLabel: l10n.accountDelete, 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()); 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. /// A change of password's first step.
Future<protocol.PasswordChallenge> beginChange(); 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. /// Changes the password: [answer] to [challenge] proves the old one.
Future<void> change( Future<void> change(
protocol.PasswordChallenge challenge, protocol.PasswordChallenge challenge,

View file

@ -344,6 +344,29 @@ class PasswordRepository(
_changed(); _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 /// A new recovery phrase for the signed-in person's keys, to show; the
/// instance takes it with [keepPhrase]. /// instance takes it with [keepPhrase].
Future<NewPhrase> newPhrase() async { Future<NewPhrase> newPhrase() async {

View file

@ -179,6 +179,28 @@ class ServerpodPasswordGateway(
Future<protocol.PasswordChallenge> beginChange() => Future<protocol.PasswordChallenge> beginChange() =>
_failing(_base.password.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 @override
Future<void> change( Future<void> change(
protocol.PasswordChallenge challenge, 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); 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 @override
Future<void> change( Future<void> change(
protocol.PasswordChallenge challenge, protocol.PasswordChallenge challenge,

View file

@ -173,6 +173,54 @@ void main() {
expect(find.text('Signed out'), findsOneWidget); 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 { testWidgets('speaks German', (tester) async {
await start(tester); await start(tester);
final german = lookupHalkyonAppLocalizations(const Locale('de')); final german = lookupHalkyonAppLocalizations(const Locale('de'));

View file

@ -302,6 +302,28 @@ void main() {
expect(anna.password, 'correct horse'); 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 ' test('makes a new recovery phrase, which the instance takes for the '
'password and a code alone', () async { 'password and a code alone', () async {
final phrase = await passwords.newPhrase(); final phrase = await passwords.newPhrase();

View file

@ -20,6 +20,8 @@ import 'package:halkyon_base_client/src/protocol/password/authenticator.dart'
as _ivfmflch; as _ivfmflch;
import 'package:halkyon_base_client/src/protocol/password/authenticator_enrolment.dart' import 'package:halkyon_base_client/src/protocol/password/authenticator_enrolment.dart'
as _it1lo7kq; as _it1lo7kq;
import 'package:halkyon_base_client/src/protocol/password/deletion_proof.dart'
as _ifxctuoq;
import 'package:halkyon_base_client/src/protocol/password/key_proof_challenge.dart' import 'package:halkyon_base_client/src/protocol/password/key_proof_challenge.dart'
as _ir3ecgdn; as _ir3ecgdn;
import 'package:halkyon_base_client/src/protocol/password/new_password.dart' import 'package:halkyon_base_client/src/protocol/password/new_password.dart'
@ -57,12 +59,16 @@ class EndpointAccount extends _isc.EndpointRef {
/// 2026-09-30). Their other devices learn at once that their sessions /// 2026-09-30). Their other devices learn at once that their sessions
/// ended. An account deletes everywhere (ADR 0016): on a member, the /// ended. An account deletes everywhere (ADR 0016): on a member, the
/// register deletes it and tells every member, this one deleting its own /// register deletes it and tells every member, this one deleting its own
/// data at once; an anonymous person is this service's alone. /// data at once; an anonymous person is this service's alone. An account
_ida.Future<void> delete() => caller.callServerEndpoint<void>( /// with a password deletes itself only with [proof] (the password's proof
'halkyon_base.account', /// and a code, `requireOwnerToDelete`), which the register checks, its
'delete', /// host the one the device addressed here.
{}, _ida.Future<void> delete({_ifxctuoq.DeletionProof? proof}) =>
); caller.callServerEndpoint<void>(
'halkyon_base.account',
'delete',
{'proof': proof},
);
/// How the caller signs in besides their sessions: with GitHub, with an /// How the caller signs in besides their sessions: with GitHub, with an
/// email address, both, or neither (anonymous). /// email address, both, or neither (anonymous).

View file

@ -0,0 +1,155 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// What deleting an account that has a password takes (ADR 0017; the second
/// reading): the password's SRP proof for a challenge `password.beginChange`
/// gave, a TOTP or recovery code, and the host the device addressed, as a
/// change of password does. A session alone deletes only an account without
/// one.
abstract class DeletionProof
implements _isc.SerializableModel, _isc.ProtocolSerialization {
DeletionProof._({
required this.challenge,
required this.clientValue,
required this.clientProof,
this.totpCode,
this.recoveryCode,
required this.host,
});
factory DeletionProof({
required _isc.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
required String host,
}) = _DeletionProofImpl;
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
return DeletionProof(
challenge: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['challenge'],
),
clientValue: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['clientValue'],
),
clientProof: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['clientProof'],
),
totpCode: jsonSerialization['totpCode'] as String?,
recoveryCode: jsonSerialization['recoveryCode'] as String?,
host: jsonSerialization['host'] as String,
);
}
_isc.UuidValue challenge;
_idt.ByteData clientValue;
_idt.ByteData clientProof;
String? totpCode;
String? recoveryCode;
/// The host the device addressed the instance by: the server it reached
/// checks it is its own before the register binds the proof to it.
String host;
/// Returns a shallow copy of this [DeletionProof]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
DeletionProof copyWith({
_isc.UuidValue? challenge,
_idt.ByteData? clientValue,
_idt.ByteData? clientProof,
String? totpCode,
String? recoveryCode,
String? host,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.DeletionProof',
'challenge': challenge.toJson(),
'clientValue': clientValue.toJson(),
'clientProof': clientProof.toJson(),
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
'host': host,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.DeletionProof',
'challenge': challenge.toJson(),
'clientValue': clientValue.toJson(),
'clientProof': clientProof.toJson(),
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
'host': host,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _DeletionProofImpl extends DeletionProof {
_DeletionProofImpl({
required _isc.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
required String host,
}) : super._(
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
host: host,
);
/// Returns a shallow copy of this [DeletionProof]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
DeletionProof copyWith({
_isc.UuidValue? challenge,
_idt.ByteData? clientValue,
_idt.ByteData? clientProof,
Object? totpCode = _Undefined,
Object? recoveryCode = _Undefined,
String? host,
}) {
return DeletionProof(
challenge: challenge ?? this.challenge,
clientValue: clientValue ?? this.clientValue.clone(),
clientProof: clientProof ?? this.clientProof.clone(),
totpCode: totpCode is String? ? totpCode : this.totpCode,
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
host: host ?? this.host,
);
}
}

View file

@ -27,6 +27,7 @@ import 'instance/instance_basics.dart' as _i8jixqx6;
import 'password/account_reset_kind.dart' as _i91ka1it; import 'password/account_reset_kind.dart' as _i91ka1it;
import 'password/authenticator.dart' as _icpzuvvi; import 'password/authenticator.dart' as _icpzuvvi;
import 'password/authenticator_enrolment.dart' as _iv7tra7v; import 'password/authenticator_enrolment.dart' as _iv7tra7v;
import 'password/deletion_proof.dart' as _ix1u7hdq;
import 'password/key_proof_challenge.dart' as _iehtx95q; import 'password/key_proof_challenge.dart' as _iehtx95q;
import 'password/new_password.dart' as _itvvd6m6; import 'password/new_password.dart' as _itvvd6m6;
import 'password/password_challenge.dart' as _iuxisac7; import 'password/password_challenge.dart' as _iuxisac7;
@ -51,6 +52,7 @@ export 'instance/instance_basics.dart';
export 'password/account_reset_kind.dart'; export 'password/account_reset_kind.dart';
export 'password/authenticator.dart'; export 'password/authenticator.dart';
export 'password/authenticator_enrolment.dart'; export 'password/authenticator_enrolment.dart';
export 'password/deletion_proof.dart';
export 'password/key_proof_challenge.dart'; export 'password/key_proof_challenge.dart';
export 'password/new_password.dart'; export 'password/new_password.dart';
export 'password/password_challenge.dart'; export 'password/password_challenge.dart';
@ -140,6 +142,9 @@ class Protocol extends _isc.SerializationManager {
if (t == _iv7tra7v.AuthenticatorEnrolment) { if (t == _iv7tra7v.AuthenticatorEnrolment) {
return _iv7tra7v.AuthenticatorEnrolment.fromJson(data) as T; return _iv7tra7v.AuthenticatorEnrolment.fromJson(data) as T;
} }
if (t == _ix1u7hdq.DeletionProof) {
return _ix1u7hdq.DeletionProof.fromJson(data) as T;
}
if (t == _iehtx95q.KeyProofChallenge) { if (t == _iehtx95q.KeyProofChallenge) {
return _iehtx95q.KeyProofChallenge.fromJson(data) as T; return _iehtx95q.KeyProofChallenge.fromJson(data) as T;
} }
@ -228,6 +233,10 @@ class Protocol extends _isc.SerializationManager {
: null) : null)
as T; as T;
} }
if (t == _isc.getType<_ix1u7hdq.DeletionProof?>()) {
return (data != null ? _ix1u7hdq.DeletionProof.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_iehtx95q.KeyProofChallenge?>()) { if (t == _isc.getType<_iehtx95q.KeyProofChallenge?>()) {
return (data != null ? _iehtx95q.KeyProofChallenge.fromJson(data) : null) return (data != null ? _iehtx95q.KeyProofChallenge.fromJson(data) : null)
as T; as T;
@ -333,6 +342,7 @@ class Protocol extends _isc.SerializationManager {
_i91ka1it.AccountResetKind => 'AccountResetKind', _i91ka1it.AccountResetKind => 'AccountResetKind',
_icpzuvvi.Authenticator => 'Authenticator', _icpzuvvi.Authenticator => 'Authenticator',
_iv7tra7v.AuthenticatorEnrolment => 'AuthenticatorEnrolment', _iv7tra7v.AuthenticatorEnrolment => 'AuthenticatorEnrolment',
_ix1u7hdq.DeletionProof => 'DeletionProof',
_iehtx95q.KeyProofChallenge => 'KeyProofChallenge', _iehtx95q.KeyProofChallenge => 'KeyProofChallenge',
_itvvd6m6.NewPassword => 'NewPassword', _itvvd6m6.NewPassword => 'NewPassword',
_iuxisac7.PasswordChallenge => 'PasswordChallenge', _iuxisac7.PasswordChallenge => 'PasswordChallenge',
@ -383,6 +393,8 @@ class Protocol extends _isc.SerializationManager {
return 'Authenticator'; return 'Authenticator';
case _iv7tra7v.AuthenticatorEnrolment(): case _iv7tra7v.AuthenticatorEnrolment():
return 'AuthenticatorEnrolment'; return 'AuthenticatorEnrolment';
case _ix1u7hdq.DeletionProof():
return 'DeletionProof';
case _iehtx95q.KeyProofChallenge(): case _iehtx95q.KeyProofChallenge():
return 'KeyProofChallenge'; return 'KeyProofChallenge';
case _itvvd6m6.NewPassword(): case _itvvd6m6.NewPassword():
@ -450,6 +462,9 @@ class Protocol extends _isc.SerializationManager {
if (dataClassName == 'AuthenticatorEnrolment') { if (dataClassName == 'AuthenticatorEnrolment') {
return deserialize<_iv7tra7v.AuthenticatorEnrolment>(data['data']); return deserialize<_iv7tra7v.AuthenticatorEnrolment>(data['data']);
} }
if (dataClassName == 'DeletionProof') {
return deserialize<_ix1u7hdq.DeletionProof>(data['data']);
}
if (dataClassName == 'KeyProofChallenge') { if (dataClassName == 'KeyProofChallenge') {
return deserialize<_iehtx95q.KeyProofChallenge>(data['data']); return deserialize<_iehtx95q.KeyProofChallenge>(data['data']);
} }

View file

@ -245,6 +245,11 @@ Built on the three parts above, on the register (members forward every step thro
password; `password.setRecoveryKeys` replaces the phrase's wrapping with the same password; `password.setRecoveryKeys` replaces the phrase's wrapping with the same
proof and code (a phrase of one's own would otherwise set a new password through the proof and code (a phrase of one's own would otherwise set a new password through the
phrase's reset). A session alone changes neither, nor a session and a code. phrase's reset). A session alone changes neither, nor a session and a code.
- **Deleting an account with a password** (`account.delete(proof:)`, a `DeletionProof`)
takes the same: the password's proof for a `beginChange` challenge and a code, bound
to the host the device addressed (`requireOwnerToDelete`; a member passes the proof
on and the register checks it). An account without a password deletes with its
session, as before.
- **Sessions end everywhere:** a change of password ends every other session of the - **Sessions end everywhere:** a change of password ends every other session of the
account; a reset of the password or the account ends all. The register ends its own account; a reset of the password or the account ends all. The register ends its own
and keeps a `sessions_ended_notice` per member (told at once and hourly until it and keeps a `sessions_ended_notice` per member (told at once and hourly until it

View file

@ -2,6 +2,8 @@ import 'package:halkyon_base_server/src/auth/account.dart';
import 'package:halkyon_base_server/src/auth/membership.dart'; import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/auth/scopes.dart'; import 'package:halkyon_base_server/src/auth/scopes.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart'; import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/password_change.dart';
import 'package:halkyon_base_server/src/register/account_register.dart'; import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart'; import 'package:serverpod_auth_idp_server/core.dart';
@ -16,13 +18,19 @@ class AccountEndpoint extends Endpoint {
/// 2026-09-30). Their other devices learn at once that their sessions /// 2026-09-30). Their other devices learn at once that their sessions
/// ended. An account deletes everywhere (ADR 0016): on a member, the /// ended. An account deletes everywhere (ADR 0016): on a member, the
/// register deletes it and tells every member, this one deleting its own /// register deletes it and tells every member, this one deleting its own
/// data at once; an anonymous person is this service's alone. /// data at once; an anonymous person is this service's alone. An account
Future<void> delete(Session session) async { /// with a password deletes itself only with [proof] (the password's proof
/// and a code, `requireOwnerToDelete`), which the register checks, its
/// host the one the device addressed here.
Future<void> delete(Session session, {DeletionProof? proof}) async {
final pod = session.serverpod;
final person = session.authenticated!.authUserId; final person = session.authenticated!.authUserId;
if (isMember(session.serverpod) && final bound = proof?.copyWith(host: deviceHostOn(pod, proof.host));
session.authenticated!.scopes.contains(identified)) { if (!isMember(pod)) {
await AccountRegister.of(session.serverpod) await requireOwnerToDelete(session, person, bound);
.deleteAccount(session, person); } else if (session.authenticated!.scopes.contains(identified)) {
await AccountRegister.of(pod)
.deleteAccount(session, person, proof: bound);
} }
await deletePerson(session, person); await deletePerson(session, person);
await session.messages.authenticationRevoked( await session.messages.authenticationRevoked(

View file

@ -11,6 +11,8 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt; import 'dart:typed_data' as _idt;
import 'package:halkyon_base_server/src/generated/password/deletion_proof.dart'
as _i619l6vv;
import 'package:halkyon_base_server/src/generated/password/new_password.dart' import 'package:halkyon_base_server/src/generated/password/new_password.dart'
as _ijye8mcv; as _ijye8mcv;
import 'package:halkyon_base_server/src/generated/password/password_setup.dart' import 'package:halkyon_base_server/src/generated/password/password_setup.dart'
@ -117,13 +119,22 @@ class Endpoints extends _is.EndpointDispatch {
methodConnectors: { methodConnectors: {
'delete': _is.MethodConnector( 'delete': _is.MethodConnector(
name: 'delete', name: 'delete',
params: {}, params: {
'proof': _is.ParameterDescription(
name: 'proof',
type: _is.getType<_i619l6vv.DeletionProof?>(),
nullable: true,
),
},
call: call:
( (
_is.Session session, _is.Session session,
Map<String, dynamic> params, Map<String, dynamic> params,
) async => (endpoints['account'] as _ii6ycf6v.AccountEndpoint) ) async =>
.delete(session), (endpoints['account'] as _ii6ycf6v.AccountEndpoint).delete(
session,
proof: params['proof'],
),
), ),
'signInWays': _is.MethodConnector( 'signInWays': _is.MethodConnector(
name: 'signInWays', name: 'signInWays',

View file

@ -0,0 +1,155 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod/serverpod.dart' as _is;
/// What deleting an account that has a password takes (ADR 0017; the second
/// reading): the password's SRP proof for a challenge `password.beginChange`
/// gave, a TOTP or recovery code, and the host the device addressed, as a
/// change of password does. A session alone deletes only an account without
/// one.
abstract class DeletionProof
implements _is.SerializableModel, _is.ProtocolSerialization {
DeletionProof._({
required this.challenge,
required this.clientValue,
required this.clientProof,
this.totpCode,
this.recoveryCode,
required this.host,
});
factory DeletionProof({
required _is.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
required String host,
}) = _DeletionProofImpl;
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
return DeletionProof(
challenge: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['challenge'],
),
clientValue: _is.ByteDataJsonExtension.fromJson(
jsonSerialization['clientValue'],
),
clientProof: _is.ByteDataJsonExtension.fromJson(
jsonSerialization['clientProof'],
),
totpCode: jsonSerialization['totpCode'] as String?,
recoveryCode: jsonSerialization['recoveryCode'] as String?,
host: jsonSerialization['host'] as String,
);
}
_is.UuidValue challenge;
_idt.ByteData clientValue;
_idt.ByteData clientProof;
String? totpCode;
String? recoveryCode;
/// The host the device addressed the instance by: the server it reached
/// checks it is its own before the register binds the proof to it.
String host;
/// Returns a shallow copy of this [DeletionProof]
/// with some or all fields replaced by the given arguments.
@_is.useResult
DeletionProof copyWith({
_is.UuidValue? challenge,
_idt.ByteData? clientValue,
_idt.ByteData? clientProof,
String? totpCode,
String? recoveryCode,
String? host,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.DeletionProof',
'challenge': challenge.toJson(),
'clientValue': clientValue.toJson(),
'clientProof': clientProof.toJson(),
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
'host': host,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.DeletionProof',
'challenge': challenge.toJson(),
'clientValue': clientValue.toJson(),
'clientProof': clientProof.toJson(),
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
'host': host,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _DeletionProofImpl extends DeletionProof {
_DeletionProofImpl({
required _is.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
required String host,
}) : super._(
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
host: host,
);
/// Returns a shallow copy of this [DeletionProof]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
DeletionProof copyWith({
_is.UuidValue? challenge,
_idt.ByteData? clientValue,
_idt.ByteData? clientProof,
Object? totpCode = _Undefined,
Object? recoveryCode = _Undefined,
String? host,
}) {
return DeletionProof(
challenge: challenge ?? this.challenge,
clientValue: clientValue ?? this.clientValue.clone(),
clientProof: clientProof ?? this.clientProof.clone(),
totpCode: totpCode is String? ? totpCode : this.totpCode,
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
host: host ?? this.host,
);
}
}

Some files were not shown because too many files have changed in this diff Show more