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
78 changed files with 42026 additions and 121 deletions

View file

@ -0,0 +1,33 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "email_code_request",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "purpose",
"fieldName": "purpose",
"columnType": 0,
"isNullable": true,
"dartType": "protocol:EmailCodePurpose?"
}
],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -23,6 +23,7 @@ part '20261020125117584-sessions-ended/migration.dart';
part '20261020185750538-recovery-proof-key/migration.dart'; part '20261020185750538-recovery-proof-key/migration.dart';
part '20261020194228286-second-factor-waits/migration.dart'; part '20261020194228286-second-factor-waits/migration.dart';
part '20261020204619104-session-code-tries/migration.dart'; part '20261020204619104-session-code-tries/migration.dart';
part '20261020211224692-email-code-purpose/migration.dart';
/// Migration registry for the client-side database. /// Migration registry for the client-side database.
class MigrationRegistry { class MigrationRegistry {
@ -48,6 +49,7 @@ class MigrationRegistry {
_Migration20261020185750538_recovery_proof_key(), _Migration20261020185750538_recovery_proof_key(),
_Migration20261020194228286_second_factor_waits(), _Migration20261020194228286_second_factor_waits(),
_Migration20261020204619104_session_code_tries(), _Migration20261020204619104_session_code_tries(),
_Migration20261020211224692_email_code_purpose(),
]; ];
/// List of all client-side database migration versions. /// List of all client-side database migration versions.

View file

@ -0,0 +1,33 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "email_code_request",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "purpose",
"fieldName": "purpose",
"columnType": 0,
"isNullable": true,
"dartType": "protocol:EmailCodePurpose?"
}
],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,57 @@
BEGIN;
--
-- ACTION ALTER TABLE
--
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text;
--
-- MIGRATION VERSION FOR halkyon_learn
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_learn', '20261020211224692-email-code-purpose', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261020211224692-email-code-purpose', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_base
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_base', '20261020211224310-email-code-purpose', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261020211224310-email-code-purpose', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_idp
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_idp', '20260924105404509', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_offline_sync
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_offline_sync', '20260914143806119', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260914143806119', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
COMMIT;

View file

@ -40,3 +40,4 @@
20261020185750538-recovery-proof-key 20261020185750538-recovery-proof-key
20261020194228286-second-factor-waits 20261020194228286-second-factor-waits
20261020204619104-session-code-tries 20261020204619104-session-code-tries
20261020211224692-email-code-purpose

View file

@ -0,0 +1,29 @@
{
"__className__": "serverpod.DatabaseDefinition",
"schemaVersion": 2,
"moduleName": "halkyon_todo",
"tables": [],
"installedModules": [
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "halkyon_base",
"version": "20261020211224310-email-code-purpose"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod",
"version": "20260824182259319"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_auth_idp",
"version": "20260924105404509"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_auth_core",
"version": "20260924105232991"
}
],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,33 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "email_code_request",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "purpose",
"fieldName": "purpose",
"columnType": 0,
"isNullable": true,
"dartType": "protocol:EmailCodePurpose?"
}
],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,49 @@
BEGIN;
--
-- ACTION ALTER TABLE
--
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text;
--
-- MIGRATION VERSION FOR halkyon_todo
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_todo', '20261020211225101-email-code-purpose', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261020211225101-email-code-purpose', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_base
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_base', '20261020211224310-email-code-purpose', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261020211224310-email-code-purpose', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_idp
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_idp', '20260924105404509', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
COMMIT;

View file

@ -12,3 +12,4 @@
20261020185752014-recovery-proof-key 20261020185752014-recovery-proof-key
20261020194229631-second-factor-waits 20261020194229631-second-factor-waits
20261020204619863-session-code-tries 20261020204619863-session-code-tries
20261020211225101-email-code-purpose

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/data/sodium_account_crypto.dart';
export 'src/password/model/account_authenticator.dart'; export 'src/password/model/account_authenticator.dart';
export 'src/password/model/account_security.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/password_failed.dart';
export 'src/password/model/phrase_check.dart'; export 'src/password/model/phrase_check.dart';
export 'src/password/model/unlocked_account_keys.dart'; export 'src/password/model/unlocked_account_keys.dart';

View file

@ -1,6 +1,7 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/password/data/password_repository.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'; import 'package:halkyon_app/src/password/model/password_failed.dart';
/// Where getting back into an account stands. /// 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. /// The address to reset the account of: a code is mailed to it.
Future<void> startAddressEntered(String address) => _run(() async { 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( emit(
state.copyWith( state.copyWith(
step: AccountRecoveryStep.startCode, step: AccountRecoveryStep.startCode,
@ -132,7 +137,11 @@ class AccountRecoveryCubit(
/// Mails a new code. /// Mails a new code.
Future<void> mailCodeResent() => _run(() async { 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. /// 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/account_crypto.dart';
import 'package:halkyon_app/src/password/data/password_repository.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/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/password_failed.dart';
import 'package:halkyon_app/src/password/model/phrase_check.dart'; import 'package:halkyon_app/src/password/model/phrase_check.dart';
@ -227,13 +228,26 @@ class AccountSetupCubit(
await _submit(); await _submit();
return; 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)); 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. /// Mails a new code.
Future<void> mailCodeResent() => _run(() async { 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. /// The code mailed: the setup goes to the instance.

Some files were not shown because too many files have changed in this diff Show more