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

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();