fix(auth): every call with a code after the password takes the address

Whoever guessed codes decided whether the owner was mailed (the re-review's
fourth finding): the address a sign-in sent was optional, so a guesser left
it out and no mail went, and a wait begun without a mail used up the one
mail there was. Every call carrying a code after the password or the phrase
proved (signing in, the phrase's reset, a change of password, a new phrase,
deletion) now takes the account's address and the language of its mails,
and is refused (wrongCredentials, as anything wrong after the password)
before any code is checked when the address isn't the account's; every
wait mails the owner, not only the first. The app sends the address the
person typed, or for changes while signed in the one this device signed in
with, and says so where it doesn't know it (addressUnknown).

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:08:25 +02:00
commit 2a21672b43
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
36 changed files with 795 additions and 347 deletions

View file

@ -11,6 +11,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
import '../auth/email_sign_in_refusal.dart' as _in9x16lz;
/// Mailing a code, accepting it, or signing in with an email address was

View file

@ -11,6 +11,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
import '../auth/github_sign_in_refusal.dart' as _irgbwy1y;
/// Signing in with GitHub, or linking a GitHub account, was refused; nothing

View file

@ -11,6 +11,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:halkyon_base_server/src/generated/password/deletion_proof.dart'
as _i619l6vv;
import 'package:halkyon_base_server/src/generated/password/new_password.dart'
@ -24,6 +25,7 @@ import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais;
import '../auth/account_endpoint.dart' as _ii6ycf6v;
import '../auth/anonymous_idp_endpoint.dart' as _in0zita6;
import '../auth/email_code_endpoint.dart' as _in3cimlf;
@ -433,13 +435,13 @@ class Endpoints extends _is.EndpointDispatch {
),
'address': _is.ParameterDescription(
name: 'address',
type: _is.getType<String?>(),
nullable: true,
type: _is.getType<String>(),
nullable: false,
),
'language': _is.ParameterDescription(
name: 'language',
type: _is.getType<String?>(),
nullable: true,
type: _is.getType<String>(),
nullable: false,
),
},
call:
@ -750,6 +752,16 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<String>(),
nullable: false,
),
'address': _is.ParameterDescription(
name: 'address',
type: _is.getType<String>(),
nullable: false,
),
'language': _is.ParameterDescription(
name: 'language',
type: _is.getType<String>(),
nullable: false,
),
},
call:
(
@ -765,6 +777,8 @@ class Endpoints extends _is.EndpointDispatch {
recoveryCode: params['recoveryCode'],
password: params['password'],
host: params['host'],
address: params['address'],
language: params['language'],
),
),
'setRecoveryKeys': _is.MethodConnector(
@ -810,6 +824,16 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<String>(),
nullable: false,
),
'address': _is.ParameterDescription(
name: 'address',
type: _is.getType<String>(),
nullable: false,
),
'language': _is.ParameterDescription(
name: 'language',
type: _is.getType<String>(),
nullable: false,
),
},
call:
(
@ -826,6 +850,8 @@ class Endpoints extends _is.EndpointDispatch {
recoveryKeys: params['recoveryKeys'],
recoveryProofKey: params['recoveryProofKey'],
host: params['host'],
address: params['address'],
language: params['language'],
),
),
},
@ -900,13 +926,13 @@ class Endpoints extends _is.EndpointDispatch {
),
'address': _is.ParameterDescription(
name: 'address',
type: _is.getType<String?>(),
nullable: true,
type: _is.getType<String>(),
nullable: false,
),
'language': _is.ParameterDescription(
name: 'language',
type: _is.getType<String?>(),
nullable: true,
type: _is.getType<String>(),
nullable: false,
),
},
call:

View file

@ -11,6 +11,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod/serverpod.dart' as _is;
/// An account's password as the register keeps it (ADR 0017): nothing that

View file

@ -11,6 +11,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
import '../password/account_reset_kind.dart' as _itrk9fro;
/// A reset asked for that takes effect only later (ADR 0017), so the

View file

@ -11,6 +11,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod/serverpod.dart' as _is;
/// What deleting an account that has a password takes (ADR 0017; the second
@ -27,6 +28,8 @@ abstract class DeletionProof
this.totpCode,
this.recoveryCode,
required this.host,
required this.address,
required this.language,
});
factory DeletionProof({
@ -36,6 +39,8 @@ abstract class DeletionProof
String? totpCode,
String? recoveryCode,
required String host,
required String address,
required String language,
}) = _DeletionProofImpl;
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
@ -52,6 +57,8 @@ abstract class DeletionProof
totpCode: jsonSerialization['totpCode'] as String?,
recoveryCode: jsonSerialization['recoveryCode'] as String?,
host: jsonSerialization['host'] as String,
address: jsonSerialization['address'] as String,
language: jsonSerialization['language'] as String,
);
}
@ -69,6 +76,13 @@ abstract class DeletionProof
/// checks it is its own before the register binds the proof to it.
String host;
/// The account's address as the device typed it, and the language its
/// mails are worded in: checked before the code, so the owner is mailed
/// should codes come to wait (the re-review's fourth finding).
String address;
String language;
/// Returns a shallow copy of this [DeletionProof]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@ -79,6 +93,8 @@ abstract class DeletionProof
String? totpCode,
String? recoveryCode,
String? host,
String? address,
String? language,
});
@override
Map<String, dynamic> toJson() {
@ -90,6 +106,8 @@ abstract class DeletionProof
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
'host': host,
'address': address,
'language': language,
};
}
@ -103,6 +121,8 @@ abstract class DeletionProof
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
'host': host,
'address': address,
'language': language,
};
}
@ -122,6 +142,8 @@ class _DeletionProofImpl extends DeletionProof {
String? totpCode,
String? recoveryCode,
required String host,
required String address,
required String language,
}) : super._(
challenge: challenge,
clientValue: clientValue,
@ -129,6 +151,8 @@ class _DeletionProofImpl extends DeletionProof {
totpCode: totpCode,
recoveryCode: recoveryCode,
host: host,
address: address,
language: language,
);
/// Returns a shallow copy of this [DeletionProof]
@ -142,6 +166,8 @@ class _DeletionProofImpl extends DeletionProof {
Object? totpCode = _Undefined,
Object? recoveryCode = _Undefined,
String? host,
String? address,
String? language,
}) {
return DeletionProof(
challenge: challenge ?? this.challenge,
@ -150,6 +176,8 @@ class _DeletionProofImpl extends DeletionProof {
totpCode: totpCode is String? ? totpCode : this.totpCode,
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
host: host ?? this.host,
address: address ?? this.address,
language: language ?? this.language,
);
}
}

View file

@ -11,8 +11,10 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:halkyon_base_server/src/generated/protocol.dart' as _ibewtqgw;
import 'package:serverpod/serverpod.dart' as _is;
import '../password/wrapped_keys.dart' as _i2rxumte;
/// What a device needs to prove the recovery phrase (ADR 0017): the

View file

@ -11,8 +11,10 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:halkyon_base_server/src/generated/protocol.dart' as _ibewtqgw;
import 'package:serverpod/serverpod.dart' as _is;
import '../password/password_costs.dart' as _ioe6tior;
import '../password/wrapped_keys.dart' as _i2rxumte;

View file

@ -11,8 +11,10 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:halkyon_base_server/src/generated/protocol.dart' as _ibewtqgw;
import 'package:serverpod/serverpod.dart' as _is;
import '../password/password_costs.dart' as _ioe6tior;
/// The register's answer to the first step of signing in with a password

View file

@ -11,8 +11,10 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:halkyon_base_server/src/generated/protocol.dart' as _ibewtqgw;
import 'package:serverpod/serverpod.dart' as _is;
import '../password/wrapped_keys.dart' as _i2rxumte;
/// What the register gives a member (or itself) once a password sign-in's

View file

@ -11,6 +11,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
import '../password/password_refusal.dart' as _i647hpdf;
/// Something to do with a password, an authenticator or a reset was refused

View file

@ -11,8 +11,10 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:halkyon_base_server/src/generated/protocol.dart' as _ibewtqgw;
import 'package:serverpod/serverpod.dart' as _is;
import '../password/password_costs.dart' as _ioe6tior;
import '../password/wrapped_keys.dart' as _i2rxumte;

View file

@ -11,8 +11,10 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:halkyon_base_server/src/generated/protocol.dart' as _ibewtqgw;
import 'package:serverpod/serverpod.dart' as _is;
import '../password/wrapped_keys.dart' as _i2rxumte;
/// Signed in with a password (ADR 0017): SRP's proof M2, which the device

View file

@ -11,6 +11,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
import '../password/sign_in_challenge_purpose.dart' as _i7xlz4xk;
/// A step a device began and must finish within two minutes, once (ADR

View file

@ -11,6 +11,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod/serverpod.dart' as _is;
/// An authenticator app an account's TOTP codes come from (ADR 0017): its

View file

@ -11,6 +11,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod/serverpod.dart' as _is;
/// An account's private keys sealed under one key (ADR 0017), as

View file

@ -11,6 +11,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
import '../profile/profile_refusal.dart' as _i5pl5k19;
/// Saving or removing a public profile was refused; nothing changed.

View file

@ -19,6 +19,7 @@ import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais;
import 'auth/email_code_request.dart' as _iqxum1j8;
import 'auth/email_identity.dart' as _id1dpd4g;
import 'auth/email_sign_in_refusal.dart' as _ieyvcag1;

View file

@ -110,10 +110,13 @@ Future<ResetPending> resetAuthenticator(
/// them, [secondFactor], which calls off every reset waiting): the account's
/// keys stay, wrapped under the new password's key by the device. Every
/// session of the account here ends. Returns the account, for the caller's
/// session.
/// session. [address] and [language]: the account's address, as the device
/// typed it, checked before the code (the owner is mailed should codes come
/// to wait).
///
/// Refused ([PasswordRefusedException]) for a new password out of range
/// ([PasswordRefusal.invalidSetup]), and as [proveRecoveryPhrase] and
/// ([PasswordRefusal.invalidSetup]), for [address] not the account's
/// ([PasswordRefusal.wrongCredentials]), and as [proveRecoveryPhrase] and
/// [secondFactor] refuse.
Future<AccountSessionsEnded> resetPasswordWithPhrase(
Session session, {
@ -123,8 +126,8 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
String? totpCode,
String? recoveryCode,
required String host,
String? address,
String? language,
required String address,
required String language,
}) async {
final keys = requirePasswordKeys(session.serverpod);
if (!isNewPassword(password)) {
@ -140,15 +143,15 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
digest: newPasswordDigest(password),
);
final account = row.authUserId;
// Before any code is checked.
final notice = await _requireNoticeOf(session, account, address, language);
await secondFactor(
session,
keys,
account,
totpCode: totpCode,
recoveryCode: recoveryCode,
notice: address == null
? null
: await _noticeOf(session, account, address, language),
notice: notice,
);
final endedBefore = await session.db.transaction((transaction) async {
await writeNewPassword(session, row, password, transaction: transaction);
@ -383,25 +386,28 @@ Future<ResetPending> _askForReset(
});
}
/// The notice of codes waiting for [account] to [typed], the address a
/// device sent, if it is the account's; null otherwise.
Future<CodesWaitNotice?> _noticeOf(
/// The notice of codes waiting for [account], to [typed] if it is the
/// account's address; refused ([PasswordRefusal.wrongCredentials])
/// otherwise, before any code is checked, as [requireNoticeTo].
Future<CodesWaitNotice> _requireNoticeOf(
Session session,
UuidValue account,
String typed,
String? language,
String language,
) async {
final to = normalizedAddress(typed);
if (to == null) {
return null;
}
// SELECT * FROM "email_identity"
// WHERE "addressHash" = $1 AND "authUserId" = $2
final identity = await EmailIdentity.db.findFirstRow(
session,
where: (t) =>
t.addressHash.equals(addressHash(session.serverpod, to)) &
t.authUserId.equals(account),
);
return identity == null ? null : (to: to, language: language ?? 'en');
final identity = to == null
? null
: await EmailIdentity.db.findFirstRow(
session,
where: (t) =>
t.addressHash.equals(addressHash(session.serverpod, to)) &
t.authUserId.equals(account),
);
if (to == null || identity == null) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
return (to: to, language: language);
}

View file

@ -71,8 +71,8 @@ class AccountRecoveryEndpoint extends Endpoint {
String? totpCode,
String? recoveryCode,
required String host,
String? address,
String? language,
required String address,
required String language,
}) async {
final ended = await AccountRegister.of(session.serverpod)
.resetPasswordWithPhrase(

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