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

View file

@ -20,6 +20,8 @@ import 'package:halkyon_base_client/src/protocol/password/authenticator.dart'
as _ivfmflch;
import 'package:halkyon_base_client/src/protocol/password/authenticator_enrolment.dart'
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'
as _ir3ecgdn;
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
/// 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.
_ida.Future<void> delete() => caller.callServerEndpoint<void>(
'halkyon_base.account',
'delete',
{},
);
/// 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.
_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
/// 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/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;
@ -51,6 +52,7 @@ export 'instance/instance_basics.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';
@ -140,6 +142,9 @@ class Protocol extends _isc.SerializationManager {
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;
}
@ -228,6 +233,10 @@ class Protocol extends _isc.SerializationManager {
: null)
as T;
}
if (t == _isc.getType<_ix1u7hdq.DeletionProof?>()) {
return (data != null ? _ix1u7hdq.DeletionProof.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_iehtx95q.KeyProofChallenge?>()) {
return (data != null ? _iehtx95q.KeyProofChallenge.fromJson(data) : null)
as T;
@ -333,6 +342,7 @@ class Protocol extends _isc.SerializationManager {
_i91ka1it.AccountResetKind => 'AccountResetKind',
_icpzuvvi.Authenticator => 'Authenticator',
_iv7tra7v.AuthenticatorEnrolment => 'AuthenticatorEnrolment',
_ix1u7hdq.DeletionProof => 'DeletionProof',
_iehtx95q.KeyProofChallenge => 'KeyProofChallenge',
_itvvd6m6.NewPassword => 'NewPassword',
_iuxisac7.PasswordChallenge => 'PasswordChallenge',
@ -383,6 +393,8 @@ class Protocol extends _isc.SerializationManager {
return 'Authenticator';
case _iv7tra7v.AuthenticatorEnrolment():
return 'AuthenticatorEnrolment';
case _ix1u7hdq.DeletionProof():
return 'DeletionProof';
case _iehtx95q.KeyProofChallenge():
return 'KeyProofChallenge';
case _itvvd6m6.NewPassword():
@ -450,6 +462,9 @@ class Protocol extends _isc.SerializationManager {
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']);
}