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
14 changed files with 873 additions and 32 deletions

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
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.
- **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
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

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/scopes.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:serverpod/serverpod.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
/// ended. An account deletes everywhere (ADR 0016): on a member, the
/// register deletes it and tells every member, this one deleting its own
/// data at once; an anonymous person is this service's alone.
Future<void> delete(Session session) async {
/// data at once; an anonymous person is this service's alone. An account
/// 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;
if (isMember(session.serverpod) &&
session.authenticated!.scopes.contains(identified)) {
await AccountRegister.of(session.serverpod)
.deleteAccount(session, person);
final bound = proof?.copyWith(host: deviceHostOn(pod, proof.host));
if (!isMember(pod)) {
await requireOwnerToDelete(session, person, bound);
} else if (session.authenticated!.scopes.contains(identified)) {
await AccountRegister.of(pod)
.deleteAccount(session, person, proof: bound);
}
await deletePerson(session, person);
await session.messages.authenticationRevoked(

View file

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

View file

@ -32,6 +32,7 @@ import 'password/account_reset.dart' as _i3fad0p6;
import 'password/account_reset_kind.dart' as _i91ka1it;
import 'password/authenticator.dart' as _icpzuvvi;
import 'password/authenticator_enrolment.dart' as _iv7tra7v;
import 'password/deletion_proof.dart' as _ix1u7hdq;
import 'password/key_proof_challenge.dart' as _iehtx95q;
import 'password/new_password.dart' as _itvvd6m6;
import 'password/password_challenge.dart' as _iuxisac7;
@ -68,6 +69,7 @@ export 'password/account_reset.dart';
export 'password/account_reset_kind.dart';
export 'password/authenticator.dart';
export 'password/authenticator_enrolment.dart';
export 'password/deletion_proof.dart';
export 'password/key_proof_challenge.dart';
export 'password/new_password.dart';
export 'password/password_challenge.dart';
@ -988,6 +990,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _iv7tra7v.AuthenticatorEnrolment) {
return _iv7tra7v.AuthenticatorEnrolment.fromJson(data) as T;
}
if (t == _ix1u7hdq.DeletionProof) {
return _ix1u7hdq.DeletionProof.fromJson(data) as T;
}
if (t == _iehtx95q.KeyProofChallenge) {
return _iehtx95q.KeyProofChallenge.fromJson(data) as T;
}
@ -1115,6 +1120,10 @@ class Protocol extends _is.DatabaseSerializationManager {
: null)
as T;
}
if (t == _is.getType<_ix1u7hdq.DeletionProof?>()) {
return (data != null ? _ix1u7hdq.DeletionProof.fromJson(data) : null)
as T;
}
if (t == _is.getType<_iehtx95q.KeyProofChallenge?>()) {
return (data != null ? _iehtx95q.KeyProofChallenge.fromJson(data) : null)
as T;
@ -1264,6 +1273,7 @@ class Protocol extends _is.DatabaseSerializationManager {
_i91ka1it.AccountResetKind => 'AccountResetKind',
_icpzuvvi.Authenticator => 'Authenticator',
_iv7tra7v.AuthenticatorEnrolment => 'AuthenticatorEnrolment',
_ix1u7hdq.DeletionProof => 'DeletionProof',
_iehtx95q.KeyProofChallenge => 'KeyProofChallenge',
_itvvd6m6.NewPassword => 'NewPassword',
_iuxisac7.PasswordChallenge => 'PasswordChallenge',
@ -1330,6 +1340,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'Authenticator';
case _iv7tra7v.AuthenticatorEnrolment():
return 'AuthenticatorEnrolment';
case _ix1u7hdq.DeletionProof():
return 'DeletionProof';
case _iehtx95q.KeyProofChallenge():
return 'KeyProofChallenge';
case _itvvd6m6.NewPassword():
@ -1429,6 +1441,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'AuthenticatorEnrolment') {
return deserialize<_iv7tra7v.AuthenticatorEnrolment>(data['data']);
}
if (dataClassName == 'DeletionProof') {
return deserialize<_ix1u7hdq.DeletionProof>(data['data']);
}
if (dataClassName == 'KeyProofChallenge') {
return deserialize<_iehtx95q.KeyProofChallenge>(data['data']);
}

View file

@ -0,0 +1,15 @@
### 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.
class: DeletionProof
fields:
challenge: UuidValue
clientValue: ByteData
clientProof: ByteData
totpCode: String?
recoveryCode: String?
### 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.
host: String

View file

@ -126,6 +126,43 @@ Future<void> setRecoveryKeys(
);
}
/// Proves [person] may delete their account: one with a password only
/// with [proof] (the password's proof and a code, bound to the host the
/// device addressed, as a change of password takes: a session alone, or a
/// phone with its authenticator, would otherwise delete the account and
/// everything encrypted under its keys); one without, by its session alone,
/// as before. Refused as [changePassword] refuses, and without [proof]
/// where one is needed ([PasswordRefusal.wrongCredentials]).
Future<void> requireOwnerToDelete(
Session session,
UuidValue person,
DeletionProof? proof,
) async {
// SELECT count(*) FROM "account_password" WHERE "authUserId" = $1
if (await AccountPassword.db.count(
session,
where: (t) => t.authUserId.equals(person),
) ==
0) {
return;
}
final keys = requirePasswordKeys(session.serverpod);
if (proof == null) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
await _proveOwner(
session,
keys,
person: person,
challenge: proof.challenge,
clientValue: proof.clientValue,
clientProof: proof.clientProof,
totpCode: proof.totpCode,
recoveryCode: proof.recoveryCode,
host: proof.host,
);
}
/// [person]'s password row, once they proved owning the account for a
/// change: the old password's proof for [challenge], [person]'s own, and a
/// [totpCode] or [recoveryCode] ([secondFactor], which also calls off a reset

View file

@ -325,8 +325,13 @@ abstract interface class AccountRegister {
});
/// Deletes the account [person] with everything the register's server has
/// of them, and has every member of the instance delete theirs.
Future<void> deleteAccount(Session session, UuidValue person);
/// of them, and has every member of the instance delete theirs: one with a
/// password only with [proof] (`requireOwnerToDelete`).
Future<void> deleteAccount(
Session session,
UuidValue person, {
DeletionProof? proof,
});
/// Those of [people] that are accounts of the register.
Future<Set<UuidValue>> knownAccounts(

View file

@ -198,7 +198,13 @@ Future<Map<String, Object?>?> _answer(
);
return {'ways': ways.toJson()};
case RegisterOperation.deleteAccount:
await register.deleteAccount(session, arguments.id('person'));
await register.deleteAccount(
session,
arguments.id('person'),
proof: arguments.has('proof')
? arguments.model('proof', DeletionProof.fromJson)
: null,
);
return const {};
case RegisterOperation.knownAccounts:
final known = await register.knownAccounts(
@ -499,6 +505,9 @@ extension type const _Arguments(Map<String, Object?> _map) {
ByteData? optionalBytes(String name) =>
optionalString(name) == null ? null : bytes(name);
/// Whether the argument [name] holds anything (not null).
bool has(String name) => _map[name] != null;
/// The model [read] makes of the object [name]; a [FormatException] for
/// what isn't one.
T model<T>(String name, T Function(Map<String, dynamic> json) read) {

View file

@ -658,7 +658,12 @@ final class LocalRegister implements AccountRegister {
);
@override
Future<void> deleteAccount(Session session, UuidValue person) async {
Future<void> deleteAccount(
Session session,
UuidValue person, {
DeletionProof? proof,
}) async {
await change.requireOwnerToDelete(session, person, proof);
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = $1
if (await AuthUser.db.findById(session, person) != null) {
await deletePerson(session, person);

View file

@ -621,8 +621,14 @@ final class RemoteRegister implements AccountRegister {
});
@override
Future<void> deleteAccount(Session session, UuidValue person) =>
_ask(RegisterOperation.deleteAccount, {'person': person.uuid});
Future<void> deleteAccount(
Session session,
UuidValue person, {
DeletionProof? proof,
}) => _ask(RegisterOperation.deleteAccount, {
'person': person.uuid,
'proof': proof?.toJson(),
});
@override
Future<Set<UuidValue>> knownAccounts(

View file

@ -504,6 +504,97 @@ void main() {
});
});
group('deleting the account', () {
/// Deletes Anna's account from her phone, with the password's proof
/// (hers by default, by [privateValue] otherwise, none [withoutProof])
/// and [totpCode] (her authenticator's now by default), bound to
/// [host].
Future<void> delete({
BigInt? privateValue,
String? totpCode,
bool withoutCode = false,
bool withoutProof = false,
String host = testHost,
}) => atNow(() async {
if (withoutProof) {
return await endpoints.account.delete(onPhone());
}
final challenge = await endpoints.password.beginChange(onPhone());
final answer = device.answer(
challenge,
privateValue: privateValue,
host: host,
);
await endpoints.account.delete(
onPhone(),
proof: DeletionProof(
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: withoutCode
? null
: totpCode ?? codeOf(enrolment, at: now),
host: testHost,
),
);
});
test(
'takes the password and a code, as a change does: a session '
"alone, or with a phone's authenticator, deletes nothing",
() async {
await expectLater(
delete(withoutProof: true),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
delete(withoutCode: true),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
delete(privateValue: BigInt.from(4242)),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
delete(totpCode: '000000'),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
delete(host: 'learn.evil.example'),
refused(PasswordRefusal.wrongCredentials),
);
expect(await AuthUser.db.findById(session, anna), isNotNull);
expect(await AccountPassword.db.count(session), 1);
nextStep();
await delete();
expect(await AuthUser.db.findById(session, anna), isNull);
expect(await AccountPassword.db.count(session), 0);
},
);
test(
'of an anonymous person takes its session alone, as before',
() async {
final ben = await newAnonymousPerson(session);
final bensLaptop = await signInNewDevice(session, ben);
await endpoints.account.delete(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{},
authId: bensLaptop.uuid,
),
),
);
expect(await AuthUser.db.findById(session, ben), isNull);
},
);
});
group('a reset', () {
test('of the password with the phrase ends the sessions at the '
'members too, the new session made after', () async {

View file

@ -433,11 +433,18 @@ final class _Register implements AccountRegister {
}) => _answer('setRecoveryKeys', () {});
@override
Future<void> deleteAccount(Session session, UuidValue person) =>
_answer('deleteAccount', () {
deleted.add(person);
accounts.remove(person);
});
Future<void> deleteAccount(
Session session,
UuidValue person, {
DeletionProof? proof,
}) => _answer('deleteAccount', () {
deleted.add(person);
deletedWith.add(proof);
accounts.remove(person);
});
/// The proofs deletions came with, as the member bound them.
final deletedWith = <DeletionProof?>[];
@override
Future<Set<UuidValue>> knownAccounts(
@ -831,6 +838,24 @@ void main() {
expect(await AuthUser.db.findById(session, anna), isNotNull);
});
test("passes the password's proof on to the register, bound to "
'the host the device addressed here', () async {
await endpoints.account.delete(
asBen(),
proof: DeletionProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '123456',
host: 'LOCALHOST',
),
);
expect(register.deleted, [ben]);
expect(register.deletedWith.single?.host, testHost);
expect(register.deletedWith.single?.totpCode, '123456');
});
test('of an anonymous person deletes it here alone', () async {
await endpoints.account.delete(asAnna());

View file

@ -22,6 +22,8 @@ import 'package:halkyon_base_server/src/generated/password/authenticator.dart'
as _ip34xal6;
import 'package:halkyon_base_server/src/generated/password/authenticator_enrolment.dart'
as _id229ey1;
import 'package:halkyon_base_server/src/generated/password/deletion_proof.dart'
as _i619l6vv;
import 'package:halkyon_base_server/src/generated/password/key_proof_challenge.dart'
as _iaysae70;
import 'package:halkyon_base_server/src/generated/password/new_password.dart'
@ -270,7 +272,10 @@ class _AccountEndpoint {
final _is.SerializationManager _serializationManager;
_ida.Future<void> delete(_ist.TestSessionBuilder sessionBuilder) async {
_ida.Future<void> delete(
_ist.TestSessionBuilder sessionBuilder, {
_i619l6vv.DeletionProof? proof,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
@ -282,7 +287,7 @@ class _AccountEndpoint {
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'account',
methodName: 'delete',
parameters: _ist.testObjectToJson({}),
parameters: _ist.testObjectToJson({'proof': proof}),
serializationManager: _serializationManager,
);
var _localReturnValue =