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:
parent
e8c4a3d472
commit
2a21672b43
14 changed files with 795 additions and 347 deletions
|
|
@ -211,5 +211,6 @@
|
|||
"passwordTooShort": "Ein neues Passwort braucht mindestens {count} Zeichen: Sollte die Datenbank der Instanz je nach außen gelangen, wird ein kurzes zuerst erraten.",
|
||||
"passwordCodesWait": "Zu viele falsche Codes hintereinander: Bis {date} wird kein Code angenommen. Waren es nicht deine, kennt jemand dein Passwort: Ändere es dann.",
|
||||
"passwordDeleteIntro": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Gib zur Bestätigung dein Passwort und einen Code deiner Authenticator-App ein.",
|
||||
"passwordSessionEnded": "Zu viele falsche Codes hintereinander von diesem Gerät: Es wurde abgemeldet. Melde dich wieder mit deinem Passwort und einem Code an."
|
||||
"passwordSessionEnded": "Zu viele falsche Codes hintereinander von diesem Gerät: Es wurde abgemeldet. Melde dich wieder mit deinem Passwort und einem Code an.",
|
||||
"passwordAddressUnknown": "Dieses Gerät kennt die Adresse deines Kontos nicht, die das braucht. Melde dich ab und wieder mit deiner Adresse und deinem Passwort an."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -458,5 +458,6 @@
|
|||
}
|
||||
},
|
||||
"passwordDeleteIntro": "Your account goes with everything in it, in every service of this instance. To confirm, type your password and a code from your authenticator app.",
|
||||
"passwordSessionEnded": "Too many wrong codes in a row from this device: it was signed out. Sign in again with your password and a code."
|
||||
"passwordSessionEnded": "Too many wrong codes in a row from this device: it was signed out. Sign in again with your password and a code.",
|
||||
"passwordAddressUnknown": "This device doesn't know your account's address, which this takes. Sign out, then sign in again with your address and password."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1374,6 +1374,12 @@ abstract class HalkyonAppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Too many wrong codes in a row from this device: it was signed out. Sign in again with your password and a code.'**
|
||||
String get passwordSessionEnded;
|
||||
|
||||
/// No description provided for @passwordAddressUnknown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This device doesn\'t know your account\'s address, which this takes. Sign out, then sign in again with your address and password.'**
|
||||
String get passwordAddressUnknown;
|
||||
}
|
||||
|
||||
class _HalkyonAppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -796,4 +796,8 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
|||
@override
|
||||
String get passwordSessionEnded =>
|
||||
'Zu viele falsche Codes hintereinander von diesem Gerät: Es wurde abgemeldet. Melde dich wieder mit deinem Passwort und einem Code an.';
|
||||
|
||||
@override
|
||||
String get passwordAddressUnknown =>
|
||||
'Dieses Gerät kennt die Adresse deines Kontos nicht, die das braucht. Melde dich ab und wieder mit deiner Adresse und deinem Passwort an.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -779,6 +779,10 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
|||
@override
|
||||
String get passwordSessionEnded =>
|
||||
'Too many wrong codes in a row from this device: it was signed out. Sign in again with your password and a code.';
|
||||
|
||||
@override
|
||||
String get passwordAddressUnknown =>
|
||||
'This device doesn\'t know your account\'s address, which this takes. Sign out, then sign in again with your address and password.';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -264,11 +264,12 @@ class NewPhraseCubit(final PasswordRepository _passwords)
|
|||
emit(NewPhraseState(step: NewPhraseStep.shown, phrase: state.phrase));
|
||||
|
||||
/// The words typed back, the [password] and a [code]: the instance keeps
|
||||
/// it.
|
||||
/// it; [language] words the owner's mail should codes come to wait.
|
||||
Future<void> checked(
|
||||
List<String> words, {
|
||||
required String password,
|
||||
required String code,
|
||||
required String language,
|
||||
}) async {
|
||||
if (!checksPhrase(state.phrase, state.checked, words)) {
|
||||
emit(_again(phraseWrong: true));
|
||||
|
|
@ -276,7 +277,12 @@ class NewPhraseCubit(final PasswordRepository _passwords)
|
|||
}
|
||||
emit(_again(busy: true));
|
||||
try {
|
||||
await _passwords.keepPhrase(_made!, password: password, code: code);
|
||||
await _passwords.keepPhrase(
|
||||
_made!,
|
||||
password: password,
|
||||
code: code,
|
||||
language: language,
|
||||
);
|
||||
if (!isClosed) {
|
||||
emit(NewPhraseState(step: NewPhraseStep.done, phrase: state.phrase));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ abstract interface class PasswordGateway() {
|
|||
|
||||
/// A sign-in's second step: [answer] to [challenge], with a TOTP code, a
|
||||
/// recovery code, or a code mailed for [request] (a setup never finished);
|
||||
/// [address] and [language] only so its owner is told should codes come
|
||||
/// to wait.
|
||||
/// [address], the one begun with, and [language]: the instance checks it
|
||||
/// before any code, and tells its owner should codes come to wait.
|
||||
Future<PasswordSignedIn> complete(
|
||||
protocol.PasswordChallenge challenge,
|
||||
SrpAnswer answer, {
|
||||
|
|
@ -55,8 +55,8 @@ abstract interface class PasswordGateway() {
|
|||
String? recoveryCode,
|
||||
String? request,
|
||||
String? code,
|
||||
String? address,
|
||||
String? language,
|
||||
required String address,
|
||||
required String language,
|
||||
});
|
||||
|
||||
/// Signs up with the address a [code] mailed for [request] proves.
|
||||
|
|
@ -107,21 +107,28 @@ abstract interface class PasswordGateway() {
|
|||
|
||||
/// Deletes the signed-in person's account, which has a password: [answer]
|
||||
/// to [challenge] ([beginChange]) proves it, with a [totpCode] or
|
||||
/// [recoveryCode]; this device then forgets its session.
|
||||
/// [recoveryCode], with the account's [address] and [language] (as a
|
||||
/// change takes them); this device then forgets its session.
|
||||
Future<void> deleteAccount(
|
||||
protocol.PasswordChallenge challenge,
|
||||
SrpAnswer answer, {
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String address,
|
||||
required String language,
|
||||
});
|
||||
|
||||
/// Changes the password: [answer] to [challenge] proves the old one.
|
||||
/// Changes the password: [answer] to [challenge] proves the old one; the
|
||||
/// account's [address], checked before the code, and [language] tell its
|
||||
/// owner should codes come to wait.
|
||||
Future<void> change(
|
||||
protocol.PasswordChallenge challenge,
|
||||
SrpAnswer answer, {
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required protocol.NewPassword password,
|
||||
required String address,
|
||||
required String language,
|
||||
});
|
||||
|
||||
/// A new recovery phrase's wrapping of the keys and its [proofKey]'s
|
||||
|
|
@ -134,6 +141,8 @@ abstract interface class PasswordGateway() {
|
|||
String? recoveryCode,
|
||||
required protocol.WrappedKeys keys,
|
||||
required ByteData proofKey,
|
||||
required String address,
|
||||
required String language,
|
||||
});
|
||||
|
||||
/// Asks for the reset of a lost authenticator: [answer] to [challenge]
|
||||
|
|
@ -161,8 +170,8 @@ abstract interface class PasswordGateway() {
|
|||
required protocol.NewPassword password,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
String? address,
|
||||
String? language,
|
||||
required String address,
|
||||
required String language,
|
||||
});
|
||||
|
||||
/// Asks for the reset of the account at [address]; when it takes effect.
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ class PasswordRepository(
|
|||
String? recoveryCode,
|
||||
String? request,
|
||||
String? code,
|
||||
String? language,
|
||||
required String language,
|
||||
}) async {
|
||||
final challenge = await _challengeOf(unlocked);
|
||||
final answer = await _crypto.answer(
|
||||
|
|
@ -325,8 +325,10 @@ class PasswordRepository(
|
|||
required String newPassword,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String language,
|
||||
}) async {
|
||||
final keys = await _keysHere();
|
||||
final address = await _addressHere();
|
||||
final challenge = await _gateway.beginChange();
|
||||
final unlock = await _crypto.unlock(
|
||||
oldPassword,
|
||||
|
|
@ -340,6 +342,8 @@ class PasswordRepository(
|
|||
totpCode: totpCode?.replaceAll(' ', ''),
|
||||
recoveryCode: recoveryCode,
|
||||
password: await _crypto.newPassword(newPassword, keys),
|
||||
address: address,
|
||||
language: language,
|
||||
);
|
||||
_changed();
|
||||
}
|
||||
|
|
@ -352,7 +356,9 @@ class PasswordRepository(
|
|||
required String password,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String language,
|
||||
}) async {
|
||||
final address = await _addressHere();
|
||||
final challenge = await _gateway.beginChange();
|
||||
final unlock = await _crypto.unlock(
|
||||
password,
|
||||
|
|
@ -364,6 +370,8 @@ class PasswordRepository(
|
|||
await _crypto.answer(unlock, challenge, host: _gateway.host),
|
||||
totpCode: totpCode?.replaceAll(' ', ''),
|
||||
recoveryCode: recoveryCode,
|
||||
address: address,
|
||||
language: language,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -383,7 +391,9 @@ class PasswordRepository(
|
|||
NewPhrase phrase, {
|
||||
required String password,
|
||||
required String code,
|
||||
required String language,
|
||||
}) async {
|
||||
final address = await _addressHere();
|
||||
final challenge = await _gateway.beginChange();
|
||||
final unlock = await _crypto.unlock(
|
||||
password,
|
||||
|
|
@ -396,6 +406,8 @@ class PasswordRepository(
|
|||
totpCode: code.replaceAll(' ', ''),
|
||||
keys: phrase._recoveryKeys,
|
||||
proofKey: phrase._recoveryProofKey,
|
||||
address: address,
|
||||
language: language,
|
||||
);
|
||||
_changed();
|
||||
}
|
||||
|
|
@ -453,7 +465,7 @@ class PasswordRepository(
|
|||
required String newPassword,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
String? language,
|
||||
required String language,
|
||||
}) async {
|
||||
final typed = address.trim();
|
||||
final challenge = await _gateway.beginKeyProof(typed);
|
||||
|
|
@ -551,4 +563,13 @@ class PasswordRepository(
|
|||
Future<UnlockedAccountKeys> _keysHere() async =>
|
||||
await _keys.read(_signedIn()) ??
|
||||
(throw const PasswordFailed(PasswordProblem.keysMissing));
|
||||
|
||||
/// The address the signed-in person signed in with on this device: what
|
||||
/// changes while signed in send, which the instance checks against the
|
||||
/// account's before any code (it keeps none, and mails its owner should
|
||||
/// codes come to wait). Refused ([PasswordProblem.addressUnknown]) where
|
||||
/// this device doesn't know it.
|
||||
Future<String> _addressHere() async =>
|
||||
await _addresses.of(_signedIn()) ??
|
||||
(throw const PasswordFailed(PasswordProblem.addressUnknown));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ class ServerpodPasswordGateway(
|
|||
String? recoveryCode,
|
||||
String? request,
|
||||
String? code,
|
||||
String? address,
|
||||
String? language,
|
||||
required String address,
|
||||
required String language,
|
||||
}) => _signedOut((base) async {
|
||||
final signedIn = await base.modules.passwordSignIn.complete(
|
||||
challenge: challenge.challenge,
|
||||
|
|
@ -185,6 +185,8 @@ class ServerpodPasswordGateway(
|
|||
SrpAnswer answer, {
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String address,
|
||||
required String language,
|
||||
}) => _failing(() async {
|
||||
await _base.account.delete(
|
||||
proof: protocol.DeletionProof(
|
||||
|
|
@ -194,6 +196,8 @@ class ServerpodPasswordGateway(
|
|||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
host: host,
|
||||
address: address,
|
||||
language: language,
|
||||
),
|
||||
);
|
||||
// The server ended every session of the person; forget this device's
|
||||
|
|
@ -208,6 +212,8 @@ class ServerpodPasswordGateway(
|
|||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required protocol.NewPassword password,
|
||||
required String address,
|
||||
required String language,
|
||||
}) => _failing(
|
||||
() => _base.password.change(
|
||||
challenge: challenge.challenge,
|
||||
|
|
@ -217,6 +223,8 @@ class ServerpodPasswordGateway(
|
|||
recoveryCode: recoveryCode,
|
||||
password: password,
|
||||
host: host,
|
||||
address: address,
|
||||
language: language,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -228,6 +236,8 @@ class ServerpodPasswordGateway(
|
|||
String? recoveryCode,
|
||||
required protocol.WrappedKeys keys,
|
||||
required ByteData proofKey,
|
||||
required String address,
|
||||
required String language,
|
||||
}) => _failing(
|
||||
() => _base.password.setRecoveryKeys(
|
||||
challenge: challenge.challenge,
|
||||
|
|
@ -238,6 +248,8 @@ class ServerpodPasswordGateway(
|
|||
recoveryKeys: keys,
|
||||
recoveryProofKey: proofKey,
|
||||
host: host,
|
||||
address: address,
|
||||
language: language,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -279,8 +291,8 @@ class ServerpodPasswordGateway(
|
|||
required protocol.NewPassword password,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
String? address,
|
||||
String? language,
|
||||
required String address,
|
||||
required String language,
|
||||
}) => _signedOut(
|
||||
(base) async => _grantOf(
|
||||
await base.modules.accountRecovery.resetPassword(
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ enum PasswordProblem() {
|
|||
/// The account has no address yet.
|
||||
noAddress,
|
||||
|
||||
/// This device doesn't know the account's address, which a change while
|
||||
/// signed in takes: sign in with it again.
|
||||
addressUnknown,
|
||||
|
||||
/// The account has no password yet.
|
||||
noPassword,
|
||||
|
||||
|
|
|
|||
|
|
@ -240,11 +240,13 @@ class _ChangePasswordState() extends State<_ChangePassword> {
|
|||
return;
|
||||
}
|
||||
final passwords = _passwordsOf(context);
|
||||
final language = mailLanguageOf(context);
|
||||
if (await _step.run(() async {
|
||||
await passwords.changePassword(
|
||||
oldPassword: _old.text,
|
||||
newPassword: _new.text,
|
||||
totpCode: _code.text,
|
||||
language: language,
|
||||
);
|
||||
return true;
|
||||
})) {
|
||||
|
|
@ -601,6 +603,7 @@ class _NewPhraseState() extends State<_NewPhrase> {
|
|||
[for (final word in _words) word.text],
|
||||
password: _password.text,
|
||||
code: _code.text,
|
||||
language: mailLanguageOf(context),
|
||||
);
|
||||
case NewPhraseStep.making || NewPhraseStep.done:
|
||||
Navigator.of(context).pop();
|
||||
|
|
|
|||
|
|
@ -38,10 +38,12 @@ class _DeleteAccountState() extends State<_DeleteAccount> {
|
|||
|
||||
Future<void> _submit() async {
|
||||
final passwords = AppScope.of(context).passwords;
|
||||
final language = mailLanguageOf(context);
|
||||
await _step.run(() async {
|
||||
await passwords.deleteAccount(
|
||||
password: _password.text,
|
||||
totpCode: _code.text,
|
||||
language: language,
|
||||
);
|
||||
return true;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ String passwordProblemText(BuildContext context, PasswordFailed failed) {
|
|||
PasswordProblem.passwordExists => l10n.passwordExists,
|
||||
PasswordProblem.addressTaken => l10n.passwordAddressTaken,
|
||||
PasswordProblem.noAddress => l10n.passwordNoAddress,
|
||||
PasswordProblem.addressUnknown => l10n.passwordAddressUnknown,
|
||||
PasswordProblem.noPassword => l10n.passwordNoPassword,
|
||||
PasswordProblem.wrongCode => l10n.passwordWrongCode,
|
||||
PasswordProblem.wrongExistingCode => l10n.passwordWrongExistingCode,
|
||||
|
|
|
|||
|
|
@ -294,11 +294,13 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
String? recoveryCode,
|
||||
String? request,
|
||||
String? code,
|
||||
String? address,
|
||||
String? language,
|
||||
required String address,
|
||||
required String language,
|
||||
}) => _call('complete', () {
|
||||
lastCompletedFor = (address: address, language: language);
|
||||
final account = _proved(challenge, answer);
|
||||
// The address begun with, checked before any code.
|
||||
_requireAddress(account, address);
|
||||
final bool needsAuthenticator;
|
||||
if (request != null && code != null && !account.finished) {
|
||||
if (_proven(request, code) != account.address) {
|
||||
|
|
@ -332,6 +334,14 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
);
|
||||
});
|
||||
|
||||
/// Refused as the register refuses [address] when it isn't [account]'s:
|
||||
/// before any code is checked.
|
||||
void _requireAddress(FakePasswordAccount account, String address) {
|
||||
if (address.trim().toLowerCase() != account.address) {
|
||||
throw const PasswordFailed(PasswordProblem.wrongCredentials);
|
||||
}
|
||||
}
|
||||
|
||||
void _setUp(FakePasswordAccount account, protocol.PasswordSetup setup) {
|
||||
account
|
||||
..password = utf8.decode(Uint8List.sublistView(setup.verifier))
|
||||
|
|
@ -518,10 +528,12 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
SrpAnswer answer, {
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String address,
|
||||
required String language,
|
||||
}) async {
|
||||
final account = await _call(
|
||||
'deleteAccount',
|
||||
() => _ownerProved(challenge, answer, totpCode, recoveryCode),
|
||||
() => _ownerProved(challenge, answer, totpCode, recoveryCode, address),
|
||||
);
|
||||
accounts.remove(account.address);
|
||||
await _auth.deleteAccount();
|
||||
|
|
@ -534,8 +546,10 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required protocol.NewPassword password,
|
||||
required String address,
|
||||
required String language,
|
||||
}) => _call('change', () {
|
||||
_ownerProved(challenge, answer, totpCode, recoveryCode)
|
||||
_ownerProved(challenge, answer, totpCode, recoveryCode, address)
|
||||
..password = utf8.decode(Uint8List.sublistView(password.verifier))
|
||||
..passwordKeys = password.passwordKeys;
|
||||
});
|
||||
|
|
@ -548,12 +562,14 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
SrpAnswer answer,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
String address,
|
||||
) {
|
||||
final account = _proved(challenge, answer);
|
||||
if (account.person != _signedIn() ||
|
||||
((totpCode ?? '').isEmpty && (recoveryCode ?? '').isEmpty)) {
|
||||
throw const PasswordFailed(PasswordProblem.wrongCredentials);
|
||||
}
|
||||
_requireAddress(account, address);
|
||||
if (_secondFactor(
|
||||
account,
|
||||
totpCode: totpCode,
|
||||
|
|
@ -572,8 +588,10 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
String? recoveryCode,
|
||||
required protocol.WrappedKeys keys,
|
||||
required ByteData proofKey,
|
||||
required String address,
|
||||
required String language,
|
||||
}) => _call('setRecoveryKeys', () {
|
||||
_ownerProved(challenge, answer, totpCode, recoveryCode)
|
||||
_ownerProved(challenge, answer, totpCode, recoveryCode, address)
|
||||
..recoveryKeys = keys
|
||||
..recoveryProofKey = proofKey;
|
||||
});
|
||||
|
|
@ -636,8 +654,8 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
required protocol.NewPassword password,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
String? address,
|
||||
String? language,
|
||||
required String address,
|
||||
required String language,
|
||||
}) => _call('resetPassword', () {
|
||||
// The challenge names the account, whose phrase's proof key must have
|
||||
// signed: never the account's own keys.
|
||||
|
|
@ -646,6 +664,7 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
!FakeAccountCrypto.proves(signature, account!.recoveryProofKey)) {
|
||||
throw const PasswordFailed(PasswordProblem.wrongCredentials);
|
||||
}
|
||||
_requireAddress(account, address);
|
||||
_secondFactor(account, totpCode: totpCode, recoveryCode: recoveryCode);
|
||||
account
|
||||
..password = utf8.decode(Uint8List.sublistView(password.verifier))
|
||||
|
|
|
|||
Loading…
Reference in a new issue