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

@ -18,6 +18,7 @@ final class _Mailbox implements CodeMailer {
Session session, {
required String to,
required String code,
required EmailCodePurpose purpose,
required String language,
}) async {
sent.add((to: to, code: code, language: language));
@ -52,6 +53,7 @@ final class _Refusing implements CodeMailer {
Session session, {
required String to,
required String code,
required EmailCodePurpose purpose,
required String language,
}) async => throw const SocketException('Connection refused');
@ -167,6 +169,7 @@ void main() {
notSignedIn(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
);
expect(
@ -174,6 +177,7 @@ void main() {
session,
request: request,
code: mailbox.codeFor('anna@example.org'),
purpose: EmailCodePurpose.signUp,
),
addressHash(session.serverpod, 'anna@example.org'),
);
@ -192,6 +196,7 @@ void main() {
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.unavailable)),
);
@ -210,6 +215,7 @@ void main() {
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.mailFailed)),
);
@ -224,6 +230,7 @@ void main() {
asAnna(),
address: typed,
language: 'en',
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.notAnAddress)),
reason: typed,
@ -238,6 +245,7 @@ void main() {
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
);
for (var i = 0; i < 3; i++) {
await expectLater(
@ -245,6 +253,7 @@ void main() {
session,
request: request,
code: 'AAAA-AAAA',
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.wrongCode)),
);
@ -254,6 +263,7 @@ void main() {
session,
request: request,
code: mailbox.codeFor('anna@example.org'),
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.tooManyTries)),
);
@ -267,6 +277,7 @@ void main() {
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
);
final lateCode = mailbox.codeFor('anna@example.org');
await withClock(
@ -276,6 +287,7 @@ void main() {
session,
request: late,
code: lateCode,
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
),
@ -287,6 +299,7 @@ void main() {
address: 'anna@example.org',
language: 'en',
network: network,
purpose: EmailCodePurpose.signUp,
);
return (request, mailbox.codeFor('anna@example.org'));
}
@ -295,18 +308,43 @@ void main() {
final (second, secondCode) = await ask('198.51.100.7');
final (third, thirdCode) = await ask('198.51.100.7');
// Three valid: the owner's still is.
await proveAddressCode(session, request: owners, code: ownersCode);
await proveAddressCode(
session,
request: owners,
code: ownersCode,
purpose: EmailCodePurpose.signUp,
);
final (fourth, fourthCode) = await ask('198.51.100.7');
final (fifth, _) = await ask('192.0.2.9');
// Five asked for, three valid: the two oldest left went.
await expectLater(
proveAddressCode(session, request: second, code: secondCode),
proveAddressCode(
session,
request: second,
code: secondCode,
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
await proveAddressCode(session, request: third, code: thirdCode);
await proveAddressCode(session, request: fourth, code: fourthCode);
await proveAddressCode(
session,
request: third,
code: thirdCode,
purpose: EmailCodePurpose.signUp,
);
await proveAddressCode(
session,
request: fourth,
code: fourthCode,
purpose: EmailCodePurpose.signUp,
);
await expectLater(
proveAddressCode(session, request: fourth, code: fourthCode),
proveAddressCode(
session,
request: fourth,
code: fourthCode,
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
expect(fifth, isNotNull);
@ -320,6 +358,7 @@ void main() {
address: address,
language: 'en',
network: network,
purpose: EmailCodePurpose.signUp,
);
for (var i = 0; i < 3; i++) {
await ask('anna@example.org', '203.0.113.1');
@ -362,11 +401,13 @@ void main() {
asAnna(),
address: 'ben@example.org',
language: 'de',
purpose: EmailCodePurpose.signUp,
);
final unknown = await endpoints.emailCode.sendCode(
asAnna(),
address: ' Nobody@Example.org ',
language: 'fr',
purpose: EmailCodePurpose.signUp,
);
expect(known, isNot(unknown));
@ -398,6 +439,7 @@ void main() {
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
);
final now = clock.now();

View file

@ -13,6 +13,7 @@ final class _NoMail implements CodeMailer {
Session session, {
required String to,
required String code,
required EmailCodePurpose purpose,
required String language,
}) async {}

View file

@ -73,12 +73,14 @@ void main() {
);
Future<({UuidValue request, String code})> mailedCode(
String address,
) async {
String address, {
EmailCodePurpose purpose = EmailCodePurpose.signUp,
}) async {
final request = await endpoints.emailCode.sendCode(
notSignedIn(),
address: address,
language: 'en',
purpose: purpose,
);
return (request: request, code: mailbox.codeFor(address));
}
@ -761,7 +763,10 @@ void main() {
});
test('not due says when it takes effect', () async {
final start = await mailedCode('anna@example.org');
final start = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.accountReset,
);
final pending = await atNow(
() => endpoints.accountRecovery.startAccountReset(
notSignedIn(),
@ -771,7 +776,10 @@ void main() {
code: start.code,
),
);
final complete = await mailedCode('anna@example.org');
final complete = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.accountReset,
);
final fresh = await PasswordDevice.make(seed: 99);
await expectLater(
atNow(

View file

@ -78,12 +78,14 @@ void main() {
/// A code mailed to [address], as the device asks for it: the request
/// and the code.
Future<({UuidValue request, String code})> mailedCode(
String address,
) async {
String address, {
EmailCodePurpose purpose = EmailCodePurpose.signUp,
}) async {
final request = await endpoints.emailCode.sendCode(
notSignedIn(),
address: address,
language: 'en',
purpose: purpose,
);
return (request: request, code: mailbox.codeFor(address));
}
@ -1572,7 +1574,10 @@ void main() {
challenge,
privateValue: privateValue,
);
final code = await mailedCode(codeFor);
final code = await mailedCode(
codeFor,
purpose: EmailCodePurpose.authenticatorReset,
);
return await endpoints.accountRecovery.resetAuthenticator(
notSignedIn(),
challenge: challenge.challenge,
@ -1586,7 +1591,7 @@ void main() {
);
});
test("carried out, forgets the codes refused before: the new "
test('carried out, forgets the codes refused before: the new '
"authenticator's codes wait for nothing of the old", () async {
await annaComplete();
for (var i = 0; i < 10; i++) {
@ -1840,7 +1845,12 @@ void main() {
),
);
final answer = device.answer(challenge);
final code = await atNow(() => mailedCode('anna@example.org'));
final code = await atNow(
() => mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.authenticatorReset,
),
);
mailbox.refusing = true;
await expectLater(
@ -2338,7 +2348,10 @@ void main() {
group('a lost password without the phrase', () {
Future<ResetPending> start({String codeFor = 'anna@example.org'}) =>
atNow(() async {
final code = await mailedCode(codeFor);
final code = await mailedCode(
codeFor,
purpose: EmailCodePurpose.accountReset,
);
return await endpoints.accountRecovery.startAccountReset(
notSignedIn(),
address: 'anna@example.org',
@ -2349,7 +2362,10 @@ void main() {
});
Future<AuthSuccess> finish(PasswordDevice fresh) => atNow(() async {
final code = await mailedCode('anna@example.org');
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.accountReset,
);
return await endpoints.accountRecovery.completeAccountReset(
notSignedIn(),
address: 'anna@example.org',
@ -2359,6 +2375,47 @@ void main() {
);
});
test('takes only a code asked for it: one asked for anything else '
'does nothing, and stays as it was', () async {
await annaComplete();
for (final purpose in EmailCodePurpose.values.where(
(each) => each != EmailCodePurpose.accountReset,
)) {
final code = await atNow(
() => mailedCode('anna@example.org', purpose: purpose),
);
await expectLater(
atNow(
() => endpoints.accountRecovery.startAccountReset(
notSignedIn(),
address: 'anna@example.org',
language: 'en',
request: code.request,
code: code.code,
),
),
refusedAsEmail(EmailSignInRefusal.codeExpired),
);
expect(
await EmailCodeRequest.db.findById(session, code.request),
isNotNull,
);
// Past the address's three codes an hour from one network.
now = now.add(const Duration(hours: 1, minutes: 1));
}
expect(await AccountReset.db.count(session), 0);
expect(mailbox.notices, isEmpty);
expect(
mailbox.codes.map((mailed) => mailed.purpose),
containsAll([
EmailCodePurpose.signUp,
EmailCodePurpose.finishSetup,
EmailCodePurpose.authenticatorReset,
EmailCodePurpose.deletion,
]),
);
});
test('carried out, forgets the codes refused before', () async {
await annaComplete();
for (var i = 0; i < 10; i++) {
@ -2449,7 +2506,10 @@ void main() {
address: 'anna@example.org',
);
final answer = device.answer(challenge);
final code = await mailedCode('anna@example.org');
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.authenticatorReset,
);
await endpoints.accountRecovery.resetAuthenticator(
notSignedIn(),
challenge: challenge.challenge,

View file

@ -59,12 +59,14 @@ void main() {
);
Future<({UuidValue request, String code})> mailedCode(
String address,
) async {
String address, {
EmailCodePurpose purpose = EmailCodePurpose.signUp,
}) async {
final request = await endpoints.emailCode.sendCode(
notSignedIn(),
address: address,
language: 'en',
purpose: purpose,
);
return (request: request, code: mailbox.codeFor(address));
}
@ -89,7 +91,12 @@ void main() {
String? wrongMailedCode,
BigInt? privateValue,
}) => atNow(() async {
final mailed = mailedTo == null ? null : await mailedCode(mailedTo);
final mailed = mailedTo == null
? null
: await mailedCode(
mailedTo,
purpose: EmailCodePurpose.finishSetup,
);
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
address: address,
@ -283,7 +290,10 @@ void main() {
test('with an account reset waiting, still signs in with the '
'password and a code, which calls the reset off', () async {
final code = await mailedCode('anna@example.org');
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.accountReset,
);
await atNow(
() => endpoints.accountRecovery.startAccountReset(
notSignedIn(),

View file

@ -158,6 +158,7 @@ final class _Register implements AccountRegister {
Session session, {
required String address,
required String language,
required EmailCodePurpose purpose,
required String network,
}) => _answer('sendSignInCode', () {
networks.add(network);
@ -543,6 +544,7 @@ void main() {
asAnna(),
address: 'anna@example.org',
language: 'de',
purpose: EmailCodePurpose.signUp,
);
expect(register.asked, ['sendSignInCode']);
@ -560,6 +562,7 @@ void main() {
asAnna(),
address: 'anna@example.org',
language: 'de',
purpose: EmailCodePurpose.signUp,
),
throwsA(
isA<EmailSignInRefusedException>().having(

View file

@ -30,6 +30,7 @@ final class _Mailbox implements CodeMailer {
Session session, {
required String to,
required String code,
required EmailCodePurpose purpose,
required String language,
}) async => sent.add((to: to, code: code));
@ -341,6 +342,7 @@ void main() {
address: 'person$i@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
);
}
@ -350,6 +352,7 @@ void main() {
address: 'person10@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
),
refused(EmailSignInRefusal.tooManyCodes),
);
@ -358,6 +361,7 @@ void main() {
address: 'person10@example.org',
language: 'en',
network: '198.51.100.4',
purpose: EmailCodePurpose.signUp,
);
expect(mailbox.sent, hasLength(11));
});
@ -373,6 +377,7 @@ void main() {
address: 'Anna@example.org',
language: 'de',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
);
await expectLater(
@ -476,6 +481,7 @@ void main() {
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
);
await remote.setPassword(
session,
@ -641,6 +647,7 @@ void main() {
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.authenticatorReset,
);
final pending = await remote.resetAuthenticator(
session,
@ -660,6 +667,7 @@ void main() {
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.accountReset,
);
await remote.startAccountReset(
session,
@ -675,6 +683,7 @@ void main() {
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.accountReset,
);
expect(
await remote.completeAccountReset(
@ -713,6 +722,7 @@ void main() {
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
);
await remote.setPassword(
session,
@ -800,6 +810,7 @@ void main() {
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.accountReset,
);
final pending = await remote.startAccountReset(
session,
@ -813,6 +824,7 @@ void main() {
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.accountReset,
);
await expectLater(
remote.completeAccountReset(
@ -850,11 +862,14 @@ void main() {
'with what the register refuses', () async {
final device = await PasswordDevice.make();
await atNow(() async {
Future<UuidValue> code() => remote.sendSignInCode(
Future<UuidValue> code([
EmailCodePurpose purpose = EmailCodePurpose.signUp,
]) => remote.sendSignInCode(
session,
address: 'carla@example.org',
language: 'en',
network: '203.0.113.7',
purpose: purpose,
);
final carla = await remote.signUpWithCode(
session,
@ -887,7 +902,7 @@ void main() {
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
request: await code(),
request: await code(EmailCodePurpose.finishSetup),
code: mailbox.codeFor('carla@example.org'),
host: testHost,
address: 'carla@example.org',

View file

@ -3,7 +3,8 @@ import 'package:serverpod/serverpod.dart';
/// Keeps every code and notice mailed, in place of a mail service.
final class Mailbox implements CodeMailer {
final codes = <({String to, String code, String language})>[];
final codes =
<({String to, String code, EmailCodePurpose purpose, String language})>[];
final notices =
<
({
@ -25,12 +26,13 @@ final class Mailbox implements CodeMailer {
Session session, {
required String to,
required String code,
required EmailCodePurpose purpose,
required String language,
}) async {
if (refusing) {
throw Exception('Connection refused');
}
codes.add((to: to, code: code, language: language));
codes.add((to: to, code: code, purpose: purpose, language: language));
}
@override

View file

@ -15,6 +15,8 @@ import 'dart:async' as _ida;
import 'dart:io' as _idi;
import 'dart:typed_data' as _idt;
import 'package:halkyon_base_server/src/generated/auth/email_code_purpose.dart'
as _i5775c0l;
import 'package:halkyon_base_server/src/generated/auth/sign_in_ways.dart'
as _ik4ah9vo;
import 'package:halkyon_base_server/src/generated/instance/instance_basics.dart'
@ -385,6 +387,7 @@ class _EmailCodeEndpoint {
_ist.TestSessionBuilder sessionBuilder, {
required String address,
required String language,
required _i5775c0l.EmailCodePurpose purpose,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -400,6 +403,7 @@ class _EmailCodeEndpoint {
parameters: _ist.testObjectToJson({
'address': address,
'language': language,
'purpose': purpose,
}),
serializationManager: _serializationManager,
);