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
18 changed files with 795 additions and 347 deletions

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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

View file

@ -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.';
}

View file

@ -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`).

View file

@ -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));
}

View file

@ -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.

View file

@ -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));
}

View file

@ -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(

View file

@ -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,

View file

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

View file

@ -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;
});

View file

@ -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,

View file

@ -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))

View file

@ -186,6 +186,8 @@ void main() {
password: 'correct horse',
crypto: dependencies.crypto,
);
// The address this device signed in with, which deleting sends.
dependencies.addresses.addresses[person] = 'anna@example.org';
Finder field(String label) => find.widgetWithText(TextField, label);
await tester.tap(find.text('Delete account'));

View file

@ -31,6 +31,7 @@ void main() {
await passwords.signIn(
await passwords.unlock('anna@example.org', 'correct horse'),
totpCode: fakeTotpCode,
language: 'en',
),
);
gateway.calls.clear();
@ -263,6 +264,7 @@ void main() {
['wrong', 'wrong', 'wrong'],
password: 'correct horse',
code: fakeTotpCode,
language: 'en',
);
expect(cubit.state, at(NewPhraseStep.check, phraseWrong: true));
// Nothing reached the instance before the words were right.
@ -275,6 +277,7 @@ void main() {
wordsAsked(cubit.state),
password: 'wrong',
code: fakeTotpCode,
language: 'en',
),
),
[
@ -286,6 +289,7 @@ void main() {
wordsAsked(cubit.state),
password: 'correct horse',
code: '000000',
language: 'en',
);
expect(
cubit.state,
@ -300,6 +304,7 @@ void main() {
wordsAsked(cubit.state),
password: 'correct horse',
code: fakeTotpCode,
language: 'en',
),
),
[

View file

@ -48,6 +48,7 @@ void main() {
final pending = await passwords.signIn(
unlocked,
totpCode: fakeTotpCode,
language: 'en',
);
expect(pending.needsAuthenticator, isFalse);
@ -71,7 +72,7 @@ void main() {
'and begins a new challenge for the next try', () async {
final wrong = await passwords.unlock('anna@example.org', 'wrong');
await expectLater(
passwords.signIn(wrong, totpCode: fakeTotpCode),
passwords.signIn(wrong, totpCode: fakeTotpCode, language: 'en'),
failsWith(PasswordProblem.wrongCredentials),
);
final right = await passwords.unlock(
@ -79,11 +80,11 @@ void main() {
'correct horse',
);
await expectLater(
passwords.signIn(right, totpCode: '000000'),
passwords.signIn(right, totpCode: '000000', language: 'en'),
failsWith(PasswordProblem.wrongCredentials),
);
await passwords.signIn(right, totpCode: fakeTotpCode);
await passwords.signIn(right, totpCode: fakeTotpCode, language: 'en');
expect(gateway.calls.where((call) => call == 'begin'), hasLength(3));
expect(auth.status, const SignedOut());
@ -98,7 +99,8 @@ void main() {
await withClock(
Clock.fixed(DateTime.utc(2026, 10, 20, 12, 2)),
() => passwords.signIn(unlocked, totpCode: fakeTotpCode),
() =>
passwords.signIn(unlocked, totpCode: fakeTotpCode, language: 'en'),
);
expect(gateway.calls, ['begin', 'begin', 'complete']);
@ -113,7 +115,7 @@ void main() {
);
await expectLater(
passwords.signIn(unlocked, totpCode: fakeTotpCode),
passwords.signIn(unlocked, totpCode: fakeTotpCode, language: 'en'),
failsWith(PasswordProblem.instanceUnproven),
);
});
@ -131,7 +133,7 @@ void main() {
'battery staple',
);
await expectLater(
passwords.signIn(unlocked),
passwords.signIn(unlocked, language: 'en'),
failsWith(PasswordProblem.setupIncomplete),
);
final request = await passwords.sendCode(
@ -143,6 +145,7 @@ void main() {
unlocked,
request: request,
code: gateway.codeFor('carla@example.org'),
language: 'en',
);
expect(pending.needsAuthenticator, isTrue);
final setup = await passwords.enrol(
@ -255,6 +258,7 @@ void main() {
final pending = await passwords.signIn(
await passwords.unlock('anna@example.org', 'correct horse'),
totpCode: fakeTotpCode,
language: 'en',
);
await passwords.enter(pending);
await settle();
@ -266,6 +270,7 @@ void main() {
oldPassword: 'correct horse',
newPassword: 'better horse',
totpCode: fakeTotpCode,
language: 'en',
);
expect(anna.password, 'better horse');
@ -276,6 +281,7 @@ void main() {
final again = await passwords.signIn(
unlocked,
totpCode: fakeTotpCode,
language: 'en',
);
expect(again.keys, keys.kept[anna.person]);
});
@ -287,6 +293,7 @@ void main() {
oldPassword: 'wrong',
newPassword: 'better horse',
totpCode: fakeTotpCode,
language: 'en',
),
failsWith(PasswordProblem.wrongCredentials),
);
@ -296,20 +303,58 @@ void main() {
oldPassword: 'correct horse',
newPassword: 'better horse',
totpCode: fakeTotpCode,
language: 'en',
),
failsWith(PasswordProblem.keysMissing),
);
expect(anna.password, 'correct horse');
});
test('sends the address this device signed in with, and says when it '
"doesn't know it, sending nothing", () async {
await passwords.changePassword(
oldPassword: 'correct horse',
newPassword: 'better horse',
totpCode: fakeTotpCode,
language: 'en',
);
expect(anna.password, 'better horse');
addresses.addresses.clear();
final calls = gateway.calls.length;
await expectLater(
passwords.changePassword(
oldPassword: 'better horse',
newPassword: 'best horse',
totpCode: fakeTotpCode,
language: 'en',
),
failsWith(PasswordProblem.addressUnknown),
);
await expectLater(
passwords.deleteAccount(
password: 'better horse',
totpCode: fakeTotpCode,
language: 'en',
),
failsWith(PasswordProblem.addressUnknown),
);
expect(gateway.calls, hasLength(calls));
expect(anna.password, 'better horse');
});
test('deletes the account with the password and a code alone, then '
'signed out here; a wrong one or none deletes nothing', () async {
await expectLater(
passwords.deleteAccount(password: 'wrong', totpCode: fakeTotpCode),
passwords.deleteAccount(
password: 'wrong',
totpCode: fakeTotpCode,
language: 'en',
),
failsWith(PasswordProblem.wrongCredentials),
);
await expectLater(
passwords.deleteAccount(password: 'correct horse'),
passwords.deleteAccount(password: 'correct horse', language: 'en'),
failsWith(PasswordProblem.wrongCredentials),
);
expect(gateway.accounts, contains('anna@example.org'));
@ -318,6 +363,7 @@ void main() {
await passwords.deleteAccount(
password: 'correct horse',
totpCode: fakeTotpCode,
language: 'en',
);
expect(gateway.accounts, isNot(contains('anna@example.org')));
@ -333,6 +379,7 @@ void main() {
phrase,
password: 'correct horse',
code: '000000',
language: 'en',
),
failsWith(PasswordProblem.wrongCredentials),
);
@ -341,6 +388,7 @@ void main() {
phrase,
password: 'wrong',
code: fakeTotpCode,
language: 'en',
),
failsWith(PasswordProblem.wrongCredentials),
);
@ -350,6 +398,7 @@ void main() {
phrase,
password: 'correct horse',
code: fakeTotpCode,
language: 'en',
);
await auth.signOut();
@ -358,6 +407,7 @@ void main() {
phrase: otherFakePhrase,
newPassword: 'after the phrase',
totpCode: fakeTotpCode,
language: 'en',
);
expect(pending.grant.authUserId, anna.person);
});
@ -392,6 +442,7 @@ void main() {
phrase: otherFakePhrase,
newPassword: 'a new password',
totpCode: fakeTotpCode,
language: 'en',
),
failsWith(PasswordProblem.phraseDoesNotFit),
);
@ -401,6 +452,7 @@ void main() {
phrase: 'too short',
newPassword: 'a new password',
totpCode: fakeTotpCode,
language: 'en',
),
failsWith(PasswordProblem.notAPhrase),
);
@ -410,6 +462,7 @@ void main() {
phrase: fakePhrase,
newPassword: 'a new password',
totpCode: fakeTotpCode,
language: 'en',
);
expect(anna.password, 'a new password');
@ -440,7 +493,10 @@ void main() {
expect(at, now.add(fakeResetDelay));
await expectLater(
withClock(Clock.fixed(now), () => passwords.signIn(unlocked)),
withClock(
Clock.fixed(now),
() => passwords.signIn(unlocked, language: 'en'),
),
throwsA(
isA<PasswordFailed>()
.having((f) => f.problem, 'problem', PasswordProblem.notDue)
@ -449,7 +505,7 @@ void main() {
);
final pending = await withClock(
Clock.fixed(at),
() => passwords.signIn(unlocked),
() => passwords.signIn(unlocked, language: 'en'),
);
expect(pending.needsAuthenticator, isTrue);
});

View file

@ -492,6 +492,7 @@ void main() {
await passwords.signIn(
await passwords.unlock('anna@example.org', 'correct horse'),
totpCode: fakeTotpCode,
language: 'en',
),
);
await show(tester);
@ -549,6 +550,7 @@ void main() {
await passwords.signIn(
await passwords.unlock('anna@example.org', 'correct horse'),
totpCode: fakeTotpCode,
language: 'en',
),
);
final before = anna.recoveryKeys;
@ -597,6 +599,7 @@ void main() {
await passwords.signIn(
await passwords.unlock('anna@example.org', 'correct horse'),
totpCode: fakeTotpCode,
language: 'en',
),
);
return anna;