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

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 {