fix(auth): a code by mail serves only what it was asked for

Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.

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 23:21:37 +02:00
commit 1d4320370b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 42026 additions and 121 deletions

View file

@ -79,6 +79,7 @@ export 'src/password/data/serverpod_password_gateway.dart';
export 'src/password/data/sodium_account_crypto.dart';
export 'src/password/model/account_authenticator.dart';
export 'src/password/model/account_security.dart';
export 'src/password/model/mail_code_purpose.dart';
export 'src/password/model/password_failed.dart';
export 'src/password/model/phrase_check.dart';
export 'src/password/model/unlocked_account_keys.dart';

View file

@ -1,6 +1,7 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/password/data/password_repository.dart';
import 'package:halkyon_app/src/password/model/mail_code_purpose.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
/// Where getting back into an account stands.
@ -121,7 +122,11 @@ class AccountRecoveryCubit(
/// The address to reset the account of: a code is mailed to it.
Future<void> startAddressEntered(String address) => _run(() async {
_request = await _passwords.sendCode(address, language: _language);
_request = await _passwords.sendCode(
address,
language: _language,
purpose: MailCodePurpose.accountReset,
);
emit(
state.copyWith(
step: AccountRecoveryStep.startCode,
@ -132,7 +137,11 @@ class AccountRecoveryCubit(
/// Mails a new code.
Future<void> mailCodeResent() => _run(() async {
_request = await _passwords.sendCode(state.address, language: _language);
_request = await _passwords.sendCode(
state.address,
language: _language,
purpose: MailCodePurpose.accountReset,
);
});
/// The code: the reset is asked for.

View file

@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/password/data/account_crypto.dart';
import 'package:halkyon_app/src/password/data/password_repository.dart';
import 'package:halkyon_app/src/password/model/account_authenticator.dart';
import 'package:halkyon_app/src/password/model/mail_code_purpose.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_app/src/password/model/phrase_check.dart';
@ -227,13 +228,26 @@ class AccountSetupCubit(
await _submit();
return;
}
_request = await _passwords.sendCode(state.address, language: _language);
_request = await _passwords.sendCode(
state.address,
language: _language,
purpose: _codePurpose,
);
emit(state.copyWith(step: AccountSetupStep.mailCode, clearProblem: true));
}
/// What the codes mailed here are for.
MailCodePurpose get _codePurpose => _mode == AccountSetupMode.finishReset
? MailCodePurpose.accountReset
: MailCodePurpose.signUp;
/// Mails a new code.
Future<void> mailCodeResent() => _run(() async {
_request = await _passwords.sendCode(state.address, language: _language);
_request = await _passwords.sendCode(
state.address,
language: _language,
purpose: _codePurpose,
);
});
/// The code mailed: the setup goes to the instance.

View file

@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/password/data/password_repository.dart';
import 'package:halkyon_app/src/password/model/account_authenticator.dart';
import 'package:halkyon_app/src/password/model/mail_code_purpose.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
/// Where signing in with a password stands.
@ -131,7 +132,13 @@ class PasswordSignInCubit(
/// Mails a new code for the step shown.
Future<void> mailCodeResent() => _run(() async {
_request = await _passwords.sendCode(state.address, language: _language);
_request = await _passwords.sendCode(
state.address,
language: _language,
purpose: state.step == PasswordSignInStep.resetMailCode
? MailCodePurpose.authenticatorReset
: MailCodePurpose.finishSetup,
);
emit(state.copyWith(clearProblem: true));
});
@ -156,7 +163,11 @@ class PasswordSignInCubit(
/// Asks for the reset of the authenticators with a code by mail: the code
/// first.
Future<void> resetByMailChosen() => _run(() async {
_request = await _passwords.sendCode(state.address, language: _language);
_request = await _passwords.sendCode(
state.address,
language: _language,
purpose: MailCodePurpose.authenticatorReset,
);
emit(
state.copyWith(
step: PasswordSignInStep.resetMailCode,
@ -255,6 +266,7 @@ class PasswordSignInCubit(
_request = await _passwords.sendCode(
state.address,
language: _language,
purpose: MailCodePurpose.finishSetup,
);
emit(
state.copyWith(step: PasswordSignInStep.mailCode, clearProblem: true),

View file

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/password/data/account_crypto.dart';
import 'package:halkyon_app/src/password/model/account_authenticator.dart';
import 'package:halkyon_app/src/password/model/mail_code_purpose.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
/// A password sign-in's second step as the instance answered it: the
@ -38,8 +39,13 @@ abstract interface class PasswordGateway() {
/// sign-in lasts there (null for no end set).
Future<({bool offered, DateTime? gitHubEnds})> offers();
/// Mails a code to [address] in [language]; the request it answers.
Future<String> sendCode(String address, {required String language});
/// Mails a code for [purpose] to [address], worded in [language]; the
/// request it answers. Only [purpose] takes it.
Future<String> sendCode(
String address, {
required String language,
required MailCodePurpose purpose,
});
/// A sign-in's first step for [address].
Future<protocol.PasswordChallenge> begin(String address);

View file

@ -11,6 +11,7 @@ import 'package:halkyon_app/src/password/data/account_key_store.dart';
import 'package:halkyon_app/src/password/data/password_gateway.dart';
import 'package:halkyon_app/src/password/model/account_authenticator.dart';
import 'package:halkyon_app/src/password/model/account_security.dart';
import 'package:halkyon_app/src/password/model/mail_code_purpose.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
@ -114,9 +115,13 @@ class PasswordRepository(
/// ends there (null for no end set, or no GitHub sign-in).
Future<({bool offered, DateTime? gitHubEnds})> offers() => _gateway.offers();
/// Mails a code to [address] in [language]; the request it answers.
Future<String> sendCode(String address, {required String language}) =>
_gateway.sendCode(address.trim(), language: language);
/// Mails a code for [purpose] to [address] in [language]; the request it
/// answers.
Future<String> sendCode(
String address, {
required String language,
required MailCodePurpose purpose,
}) => _gateway.sendCode(address.trim(), language: language, purpose: purpose);
// Signing in.

View file

@ -6,6 +6,7 @@ import 'package:halkyon_app/src/base/base_client.dart';
import 'package:halkyon_app/src/password/data/account_crypto.dart';
import 'package:halkyon_app/src/password/data/password_gateway.dart';
import 'package:halkyon_app/src/password/model/account_authenticator.dart';
import 'package:halkyon_app/src/password/model/mail_code_purpose.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:http/http.dart' as http;
@ -39,13 +40,24 @@ class ServerpodPasswordGateway(
});
@override
Future<String> sendCode(String address, {required String language}) =>
_signedOut(
(base) async => (await base.modules.emailCode.sendCode(
address: address,
language: language,
)).uuid,
);
Future<String> sendCode(
String address, {
required String language,
required MailCodePurpose purpose,
}) => _signedOut(
(base) async => (await base.modules.emailCode.sendCode(
address: address,
language: language,
purpose: switch (purpose) {
MailCodePurpose.signUp => protocol.EmailCodePurpose.signUp,
MailCodePurpose.finishSetup => protocol.EmailCodePurpose.finishSetup,
MailCodePurpose.authenticatorReset =>
protocol.EmailCodePurpose.authenticatorReset,
MailCodePurpose.accountReset => protocol.EmailCodePurpose.accountReset,
MailCodePurpose.deletion => protocol.EmailCodePurpose.deletion,
},
)).uuid,
);
@override
Future<protocol.PasswordChallenge> begin(String address) => _signedOut(

View file

@ -0,0 +1,20 @@
/// What a code by mail is for (ADR 0017): asked for with it, and taken by
/// the instance for that alone; its mail says what it would do.
enum MailCodePurpose() {
/// Proving the address of a new account, or of a password set up while
/// signed in.
signUp,
/// Signing in, with the password, to an account whose setup never
/// finished.
finishSetup,
/// Asking for the reset of a lost authenticator.
authenticatorReset,
/// Asking for, or carrying out, the reset of the whole account.
accountReset,
/// Deleting an account whose setup never finished.
deletion,
}

View file

@ -8,6 +8,7 @@ import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/password/data/account_crypto.dart';
import 'package:halkyon_app/src/password/data/password_gateway.dart';
import 'package:halkyon_app/src/password/model/account_authenticator.dart';
import 'package:halkyon_app/src/password/model/mail_code_purpose.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_app/src/testing/fake_account_crypto.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
@ -76,7 +77,15 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
final gitHubPeople = <String>{};
/// The codes mailed, in order.
final mailed = <({String address, String language, String code})>[];
final mailed =
<
({
String address,
String language,
MailCodePurpose purpose,
String code,
})
>[];
/// The calls made, by name, in order.
final calls = <String>[];
@ -157,9 +166,13 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
/// The address [request] was mailed to, for its [code]; refused as the
/// instance refuses a wrong or old code.
String _proven(String? request, String? code) {
/// The address a [code] for [request] proves, asked for [purpose]: one
/// asked for anything else proves nothing, as the instance takes it.
String _proven(String? request, String? code, MailCodePurpose purpose) {
final number = int.tryParse(request?.replaceFirst('request-', '') ?? '');
if (number == null || number > mailed.length) {
if (number == null ||
number > mailed.length ||
mailed[number - 1].purpose != purpose) {
throw const PasswordFailed(PasswordProblem.mailCodeExpired);
}
final mail = mailed[number - 1];
@ -185,19 +198,23 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
_call('offers', () => (offered: offered, gitHubEnds: gitHubEnds));
@override
Future<String> sendCode(String address, {required String language}) =>
_call('sendCode', () {
final to = address.trim().toLowerCase();
if (!to.contains('@')) {
throw const PasswordFailed(PasswordProblem.notAnAddress);
}
mailed.add((
address: to,
language: language,
code: 'CODE-${mailed.length + 1}',
));
return 'request-${mailed.length}';
});
Future<String> sendCode(
String address, {
required String language,
required MailCodePurpose purpose,
}) => _call('sendCode', () {
final to = address.trim().toLowerCase();
if (!to.contains('@')) {
throw const PasswordFailed(PasswordProblem.notAnAddress);
}
mailed.add((
address: to,
language: language,
purpose: purpose,
code: 'CODE-${mailed.length + 1}',
));
return 'request-${mailed.length}';
});
protocol.PasswordChallenge _challengeFor(String address) {
final id = protocol.UuidValue.fromString(
@ -303,7 +320,8 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
_requireAddress(account, address);
final bool needsAuthenticator;
if (request != null && code != null && !account.finished) {
if (_proven(request, code) != account.address) {
if (_proven(request, code, MailCodePurpose.finishSetup) !=
account.address) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
account.accountReset = null;
@ -358,7 +376,7 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
required String request,
required String code,
}) => _call('signUp', () {
final address = _proven(request, code);
final address = _proven(request, code, MailCodePurpose.signUp);
final account = accounts[address] ??= FakePasswordAccount(
_newPerson(),
address,
@ -379,7 +397,7 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
final person = _signedIn();
var account = _accountOfPerson(person);
if (request != null && code != null) {
final address = _proven(request, code);
final address = _proven(request, code, MailCodePurpose.signUp);
if (accounts[address] case final other? when other.person != person) {
throw const PasswordFailed(PasswordProblem.addressTaken);
}
@ -609,7 +627,8 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
}) => _call('resetAuthenticator', () {
final account = _proved(challenge, answer);
if (request != null && code != null) {
if (_proven(request, code) != account.address) {
if (_proven(request, code, MailCodePurpose.authenticatorReset) !=
account.address) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
} else if (keyChallenge == null ||
@ -679,7 +698,7 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
required String request,
required String code,
}) => _call('startAccountReset', () {
final proven = _proven(request, code);
final proven = _proven(request, code, MailCodePurpose.accountReset);
final account = accounts[proven];
if (proven != address.trim().toLowerCase() || account?.password == null) {
throw const PasswordFailed(PasswordProblem.noPassword);
@ -694,7 +713,7 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
required String request,
required String code,
}) => _call('completeAccountReset', () {
final proven = _proven(request, code);
final proven = _proven(request, code, MailCodePurpose.accountReset);
final account = accounts[proven];
if (proven != address.trim().toLowerCase() || account == null) {
throw const PasswordFailed(PasswordProblem.noReset);