fix(app): every change of how an account is secured asks for the password

The app's side of the owner's proof (OwnerProof, the third reading): adding
an authenticator once one is confirmed, removing a confirmed one and new
recovery codes now ask for the current password beside the code, as
changing the password, a new phrase and deleting already did; one
challenge and one answer per sheet (OwnerAnswer). A wrong password or code
reads as the one refusal the instance gives.

Deleting chooses a code by mail from what the instance says
(secondFactorWorks), no longer from whether an authenticator was ever
confirmed: with the authenticators' reset taken effect but not carried
out, the app offered a code field the instance refused (the fourth
finding). Signed-in calls take no address any more, so a device that
doesn't know it changes the password as any other; deleting by mail asks
for the address only where the device doesn't know it, as the code goes
there (the fifth finding).

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-21 10:42:55 +02:00
commit a0ae08f975
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
21 changed files with 468 additions and 315 deletions

View file

@ -117,6 +117,7 @@
"passwordAuthenticatorOpen": "In deiner Authenticator-App öffnen",
"passwordAuthenticatorNoApp": "Keine App auf diesem Gerät öffnet ihn. Scanne den Code oder tippe den Schlüssel ein.",
"passwordAuthenticatorCodeIntro": "Gib dann den Code ein, den sie zeigt.",
"passwordAuthenticatorOwnerIntro": "Bestätige zum Hinzufügen, dass du es bist: mit deinem Passwort und einem Code von einem bisherigen Authenticator.",
"passwordExistingCode": "Code von einem bisherigen Authenticator",
"passwordRecoveryCodesTitle": "Wiederherstellungscodes",
"passwordRecoveryCodesIntro": "Jeder meldet dich einmal anstelle eines Codes aus deinem Authenticator an, falls du ihn verlierst. Bewahre sie auf, wo nur du sie findest: Sie werden nur jetzt angezeigt.",
@ -161,7 +162,7 @@
"passwordSectionKeysMissing": "Melde dich auf diesem Gerät mit deinem Passwort an, um das zu ändern.",
"passwordSectionNotConfirmed": "Noch nicht eingerichtet",
"passwordSectionRemove": "{name} entfernen?",
"passwordSectionRemoveIntro": "Gib zur Bestätigung einen Code aus einem deiner Authenticators ein.",
"passwordSectionRemoveIntro": "Gib zur Bestätigung dein Passwort und einen Code aus einem deiner Authenticators ein.",
"passwordSectionRemoveAction": "Entfernen",
"passwordSectionAddAuthenticator": "Authenticator hinzufügen",
"passwordSectionRecoveryCodes": "Neue Wiederherstellungscodes",
@ -172,7 +173,7 @@
"passwordChangeIntro": "Deine anderen Geräte werden abgemeldet. Deine Schlüssel bleiben: Nichts geht verloren.",
"passwordChangeAction": "Passwort ändern",
"passwordChanged": "Passwort geändert. Deine anderen Geräte sind abgemeldet.",
"passwordNewCodesIntro": "Gib einen Code aus deiner Authenticator-App ein. Deine alten Wiederherstellungscodes gelten dann nicht mehr.",
"passwordNewCodesIntro": "Gib dein Passwort und einen Code aus deiner Authenticator-App ein. Deine alten Wiederherstellungscodes gelten dann nicht mehr.",
"passwordNewPhraseIntro": "Deine neue Wiederherstellungsphrase. Schreib die 24 Wörter der Reihe nach auf: Sobald du sie bestätigst, öffnet deine alte Phrase nichts mehr.",
"passwordPhraseKept": "Neue Wiederherstellungsphrase gesetzt. Die alte gilt nicht mehr.",
"passwordWrongCredentials": "Die Adresse, das Passwort oder der Code stimmt nicht.",
@ -186,7 +187,6 @@
"passwordNoAddress": "Dein Konto hat noch keine E-Mail-Adresse.",
"passwordNoPassword": "Dieses Konto hat kein Passwort.",
"passwordWrongCode": "Dieser Code stimmt nicht. Gib den ein, den deine App gerade zeigt.",
"passwordWrongExistingCode": "Der Code von deinem anderen Authenticator stimmt nicht.",
"passwordLastAuthenticator": "Dein letzter Authenticator kann nicht weg: Jede Anmeldung braucht einen.",
"passwordTooManyAuthenticators": "Dein Konto hat so viele Authenticators, wie es darf. Entferne zuerst einen.",
"passwordUnknownAuthenticator": "Dieser Authenticator ist weg.",
@ -212,8 +212,8 @@
"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.",
"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.",
"passwordDeleteIntroByMail": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Seine Authenticator-App ist nicht eingerichtet, daher bestätigt es ein Code per E-Mail: Gib dein Passwort ein, dann den Code, den wir an deine Adresse schicken.",
"passwordAddressUnknown": "Gib die E-Mail-Adresse deines Kontos ein: Dorthin geht der Code.",
"passwordDeleteIntroByMail": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Keiner seiner Authenticators funktioniert gerade, daher bestätigt es ein Code per E-Mail: Gib dein Passwort ein, dann den Code, den wir an deine Adresse schicken.",
"passwordDeleteSendCode": "Code per E-Mail schicken",
"passwordChangedSignInAgain": "Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an."
}

View file

@ -301,6 +301,7 @@
"passwordAuthenticatorOpen": "Open in your authenticator app",
"passwordAuthenticatorNoApp": "No app on this device opens it. Scan the code or type in the key.",
"passwordAuthenticatorCodeIntro": "Then enter the code it shows.",
"passwordAuthenticatorOwnerIntro": "To add it, confirm it is you: your password and a code from an authenticator you added before.",
"passwordExistingCode": "Code from an authenticator you added before",
"passwordRecoveryCodesTitle": "Recovery codes",
"passwordRecoveryCodesIntro": "Each signs in once in place of a code from your authenticator, should you lose it. Keep them where only you find them: they're shown only now.",
@ -380,7 +381,7 @@
}
}
},
"passwordSectionRemoveIntro": "Enter a code from one of your authenticators to confirm.",
"passwordSectionRemoveIntro": "Enter your password and a code from one of your authenticators to confirm.",
"passwordSectionRemoveAction": "Remove",
"passwordSectionAddAuthenticator": "Add an authenticator",
"passwordSectionRecoveryCodes": "New recovery codes",
@ -391,7 +392,7 @@
"passwordChangeIntro": "Your other devices are signed out. Your keys stay: nothing is lost.",
"passwordChangeAction": "Change password",
"passwordChanged": "Password changed. Your other devices are signed out.",
"passwordNewCodesIntro": "Enter a code from your authenticator app. Your old recovery codes stop working.",
"passwordNewCodesIntro": "Enter your password and a code from your authenticator app. Your old recovery codes stop working.",
"passwordNewPhraseIntro": "Your new recovery phrase. Write the 24 words down in order: once you confirm it, your old phrase opens nothing any more.",
"passwordPhraseKept": "New recovery phrase set. The old one no longer works.",
"passwordWrongCredentials": "The address, the password or the code isn't right.",
@ -412,7 +413,6 @@
"passwordNoAddress": "Your account has no email address yet.",
"passwordNoPassword": "This account has no password.",
"passwordWrongCode": "This code isn't right. Enter the one your app shows now.",
"passwordWrongExistingCode": "The code from your other authenticator isn't right.",
"passwordLastAuthenticator": "Your last authenticator can't go: every sign-in needs one.",
"passwordTooManyAuthenticators": "Your account has as many authenticators as it may. Remove one first.",
"passwordUnknownAuthenticator": "This authenticator is gone.",
@ -459,8 +459,8 @@
},
"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.",
"passwordAddressUnknown": "This device doesn't know your account's address, which this takes. Sign out, then sign in again with your address and password.",
"passwordDeleteIntroByMail": "Your account goes with everything in it, in every service of this instance. Its authenticator isn't set up, so a code by mail confirms it: type your password, then the code we email to your address.",
"passwordAddressUnknown": "Enter your account's email address: the code goes there.",
"passwordDeleteIntroByMail": "Your account goes with everything in it, in every service of this instance. None of its authenticators works now, so a code by mail confirms it: type your password, then the code we email to your address.",
"passwordDeleteSendCode": "Email me a code",
"passwordChangedSignInAgain": "Your password was changed, but this service couldn't sign you in just yet. Sign in again with the new password."
}

View file

@ -805,6 +805,12 @@ abstract class HalkyonAppLocalizations {
/// **'Then enter the code it shows.'**
String get passwordAuthenticatorCodeIntro;
/// No description provided for @passwordAuthenticatorOwnerIntro.
///
/// In en, this message translates to:
/// **'To add it, confirm it is you: your password and a code from an authenticator you added before.'**
String get passwordAuthenticatorOwnerIntro;
/// No description provided for @passwordExistingCode.
///
/// In en, this message translates to:
@ -1072,7 +1078,7 @@ abstract class HalkyonAppLocalizations {
/// No description provided for @passwordSectionRemoveIntro.
///
/// In en, this message translates to:
/// **'Enter a code from one of your authenticators to confirm.'**
/// **'Enter your password and a code from one of your authenticators to confirm.'**
String get passwordSectionRemoveIntro;
/// No description provided for @passwordSectionRemoveAction.
@ -1138,7 +1144,7 @@ abstract class HalkyonAppLocalizations {
/// No description provided for @passwordNewCodesIntro.
///
/// In en, this message translates to:
/// **'Enter a code from your authenticator app. Your old recovery codes stop working.'**
/// **'Enter your password and a code from your authenticator app. Your old recovery codes stop working.'**
String get passwordNewCodesIntro;
/// No description provided for @passwordNewPhraseIntro.
@ -1219,12 +1225,6 @@ abstract class HalkyonAppLocalizations {
/// **'This code isn\'t right. Enter the one your app shows now.'**
String get passwordWrongCode;
/// No description provided for @passwordWrongExistingCode.
///
/// In en, this message translates to:
/// **'The code from your other authenticator isn\'t right.'**
String get passwordWrongExistingCode;
/// No description provided for @passwordLastAuthenticator.
///
/// In en, this message translates to:
@ -1378,13 +1378,13 @@ abstract class HalkyonAppLocalizations {
/// 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.'**
/// **'Enter your account\'s email address: the code goes there.'**
String get passwordAddressUnknown;
/// No description provided for @passwordDeleteIntroByMail.
///
/// In en, this message translates to:
/// **'Your account goes with everything in it, in every service of this instance. Its authenticator isn\'t set up, so a code by mail confirms it: type your password, then the code we email to your address.'**
/// **'Your account goes with everything in it, in every service of this instance. None of its authenticators works now, so a code by mail confirms it: type your password, then the code we email to your address.'**
String get passwordDeleteIntroByMail;
/// No description provided for @passwordDeleteSendCode.

View file

@ -438,6 +438,10 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String get passwordAuthenticatorCodeIntro =>
'Gib dann den Code ein, den sie zeigt.';
@override
String get passwordAuthenticatorOwnerIntro =>
'Bestätige zum Hinzufügen, dass du es bist: mit deinem Passwort und einem Code von einem bisherigen Authenticator.';
@override
String get passwordExistingCode => 'Code von einem bisherigen Authenticator';
@ -604,7 +608,7 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get passwordSectionRemoveIntro =>
'Gib zur Bestätigung einen Code aus einem deiner Authenticators ein.';
'Gib zur Bestätigung dein Passwort und einen Code aus einem deiner Authenticators ein.';
@override
String get passwordSectionRemoveAction => 'Entfernen';
@ -642,7 +646,7 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get passwordNewCodesIntro =>
'Gib einen Code aus deiner Authenticator-App ein. Deine alten Wiederherstellungscodes gelten dann nicht mehr.';
'Gib dein Passwort und einen Code aus deiner Authenticator-App ein. Deine alten Wiederherstellungscodes gelten dann nicht mehr.';
@override
String get passwordNewPhraseIntro =>
@ -693,10 +697,6 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String get passwordWrongCode =>
'Dieser Code stimmt nicht. Gib den ein, den deine App gerade zeigt.';
@override
String get passwordWrongExistingCode =>
'Der Code von deinem anderen Authenticator stimmt nicht.';
@override
String get passwordLastAuthenticator =>
'Dein letzter Authenticator kann nicht weg: Jede Anmeldung braucht einen.';
@ -799,11 +799,11 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@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.';
'Gib die E-Mail-Adresse deines Kontos ein: Dorthin geht der Code.';
@override
String get passwordDeleteIntroByMail =>
'Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Seine Authenticator-App ist nicht eingerichtet, daher bestätigt es ein Code per E-Mail: Gib dein Passwort ein, dann den Code, den wir an deine Adresse schicken.';
'Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Keiner seiner Authenticators funktioniert gerade, daher bestätigt es ein Code per E-Mail: Gib dein Passwort ein, dann den Code, den wir an deine Adresse schicken.';
@override
String get passwordDeleteSendCode => 'Code per E-Mail schicken';

View file

@ -428,6 +428,10 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get passwordAuthenticatorCodeIntro => 'Then enter the code it shows.';
@override
String get passwordAuthenticatorOwnerIntro =>
'To add it, confirm it is you: your password and a code from an authenticator you added before.';
@override
String get passwordExistingCode =>
'Code from an authenticator you added before';
@ -592,7 +596,7 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get passwordSectionRemoveIntro =>
'Enter a code from one of your authenticators to confirm.';
'Enter your password and a code from one of your authenticators to confirm.';
@override
String get passwordSectionRemoveAction => 'Remove';
@ -628,7 +632,7 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get passwordNewCodesIntro =>
'Enter a code from your authenticator app. Your old recovery codes stop working.';
'Enter your password and a code from your authenticator app. Your old recovery codes stop working.';
@override
String get passwordNewPhraseIntro =>
@ -678,10 +682,6 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
String get passwordWrongCode =>
'This code isn\'t right. Enter the one your app shows now.';
@override
String get passwordWrongExistingCode =>
'The code from your other authenticator isn\'t right.';
@override
String get passwordLastAuthenticator =>
'Your last authenticator can\'t go: every sign-in needs one.';
@ -782,11 +782,11 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@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.';
'Enter your account\'s email address: the code goes there.';
@override
String get passwordDeleteIntroByMail =>
'Your account goes with everything in it, in every service of this instance. Its authenticator isn\'t set up, so a code by mail confirms it: type your password, then the code we email to your address.';
'Your account goes with everything in it, in every service of this instance. None of its authenticators works now, so a code by mail confirms it: type your password, then the code we email to your address.';
@override
String get passwordDeleteSendCode => 'Email me a code';

View file

@ -140,7 +140,7 @@ class const AccountTiles({
// Without a second factor that works, a code by mail confirms it.
await showDeleteAccountWithPassword(
context,
byMail: !security.finished,
byMail: !security.secondFactorWorks,
);
}
}

View file

@ -119,8 +119,9 @@ final class const AddAuthenticatorState({
}
/// Adds an authenticator to the signed-in person's account: enrolled as it
/// opens, confirmed with its code (and, once one is confirmed, a code from
/// one of those: [needsExistingCode]), the first one's recovery codes shown.
/// opens, confirmed with its code (and, once one is confirmed, the owner's
/// proof: the password and a code from one of those, [needsExistingCode]),
/// the first one's recovery codes shown.
class AddAuthenticatorCubit(
final PasswordRepository _passwords, {
required final String _label,
@ -146,8 +147,13 @@ class AddAuthenticatorCubit(
}
}
/// Confirms it with its [code] and, where needed, [existingCode].
Future<void> codeEntered(String code, {String? existingCode}) async {
/// Confirms it with its [code] and, where needed, the [password] and
/// [existingCode].
Future<void> codeEntered(
String code, {
String? password,
String? existingCode,
}) async {
final setup = state.setup!;
emit(
AddAuthenticatorState(
@ -160,6 +166,7 @@ class AddAuthenticatorCubit(
final codes = await _passwords.confirm(
setup.id,
code,
password: needsExistingCode ? password : null,
existingCode: needsExistingCode ? existingCode : null,
);
if (!isClosed) {
@ -264,12 +271,11 @@ 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; [language] words the owner's mail should codes come to wait.
/// it.
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));
@ -277,12 +283,7 @@ class NewPhraseCubit(final PasswordRepository _passwords)
}
emit(_again(busy: true));
try {
await _passwords.keepPhrase(
_made!,
password: password,
code: code,
language: language,
);
await _passwords.keepPhrase(_made!, password: password, code: code);
if (!isClosed) {
emit(NewPhraseState(step: NewPhraseStep.done, phrase: state.phrase));
}

View file

@ -20,6 +20,18 @@ typedef PasswordWays = ({
bool email,
bool password,
bool authenticator,
bool secondFactorWorks,
});
/// What a signed-in owner proves to change how their account is secured
/// (`OwnerProof`, ADR 0017; the third reading): the `answer` to the
/// `challenge` (`beginChange`) proving the password, and a TOTP `totpCode`
/// or a `recoveryCode`. A session alone gets no code checked.
typedef OwnerAnswer = ({
protocol.PasswordChallenge challenge,
SrpAnswer answer,
String? totpCode,
String? recoveryCode,
});
/// The boundary to the instance's password sign-in (ADR 0017): halkyon_base's
@ -87,23 +99,23 @@ abstract interface class PasswordGateway() {
SessionGrant? as,
});
/// Confirms authenticator [id] with its [code] (and [existingCode] from
/// one confirmed before); the recovery codes the first brings.
/// Confirms authenticator [id] with its [code] (and, once one is
/// confirmed, the [owner]'s proof); the recovery codes the first brings.
Future<List<String>> confirm(
String id,
String code, {
String? existingCode,
OwnerAnswer? owner,
SessionGrant? as,
});
/// The person's authenticators, the oldest first.
Future<List<AccountAuthenticator>> authenticators();
/// Removes authenticator [id], a confirmed one with a [code].
Future<void> remove(String id, {String? code});
/// Removes authenticator [id], a confirmed one with the [owner]'s proof.
Future<void> remove(String id, {OwnerAnswer? owner});
/// New recovery codes for a [code].
Future<List<String>> newRecoveryCodes(String code);
/// New recovery codes for the [owner]'s proof.
Future<List<String>> newRecoveryCodes(OwnerAnswer owner);
/// How the signed-in person signs in.
Future<PasswordWays> ways();
@ -114,9 +126,7 @@ 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], or where no second factor works a [code] mailed for
/// [request] asked for deleting, with the account's [address] and
/// [language] (as a change takes them); this device then forgets its
/// session.
/// [request] asked for deleting; this device then forgets its session.
Future<void> deleteAccount(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
@ -124,35 +134,21 @@ abstract interface class PasswordGateway() {
String? recoveryCode,
String? request,
String? code,
required String address,
required String language,
});
/// 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.
/// Changes the password to [password], the [owner]'s proof proving the
/// old one.
Future<void> change(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
OwnerAnswer owner, {
required protocol.NewPassword password,
required String address,
required String language,
});
/// A new recovery phrase's wrapping of the keys and its [proofKey]'s
/// public half: [answer] to [challenge] ([beginChange]) proves the
/// password, with a [totpCode] or [recoveryCode].
/// public half, for the [owner]'s proof.
Future<void> setRecoveryKeys(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
OwnerAnswer owner, {
required protocol.WrappedKeys keys,
required ByteData proofKey,
required String address,
required String language,
});
/// Asks for the reset of a lost authenticator: [answer] to [challenge]

View file

@ -267,35 +267,76 @@ class PasswordRepository(
PendingSignIn? pending,
}) => _gateway.enrol(name: name, label: label, as: pending?.grant);
/// Confirms authenticator [id] with its [code], and [existingCode] from
/// one confirmed before where the account has one; the recovery codes the
/// first brings (shown once), none for another.
/// Confirms authenticator [id] with its [code], and, where the account
/// has one confirmed already, the person's [password] and [existingCode]
/// from one of those (the owner's proof: a session alone adds no second
/// factor); the recovery codes the first brings (shown once), none for
/// another.
Future<List<String>> confirm(
String id,
String code, {
String? password,
String? existingCode,
PendingSignIn? pending,
}) async {
final codes = await _gateway.confirm(
id,
code.replaceAll(' ', ''),
existingCode: existingCode?.replaceAll(' ', ''),
owner: password == null
? null
: await _ownerAnswer(password, totpCode: existingCode),
as: pending?.grant,
);
_changed();
return codes;
}
/// Removes authenticator [id]: a confirmed one with a [code] from one of
/// the person's, while another stays.
Future<void> removeAuthenticator(String id, {String? code}) async {
await _gateway.remove(id, code: code?.replaceAll(' ', ''));
/// Removes authenticator [id]: a confirmed one with the person's
/// [password] and a [code] from one of theirs, while another stays; one
/// never confirmed with neither.
Future<void> removeAuthenticator(
String id, {
String? password,
String? code,
}) async {
await _gateway.remove(
id,
owner: password == null
? null
: await _ownerAnswer(password, totpCode: code),
);
_changed();
}
/// New recovery codes in place of the old, for a [code]: shown once.
Future<List<String>> newRecoveryCodes(String code) =>
_gateway.newRecoveryCodes(code.replaceAll(' ', ''));
/// New recovery codes in place of the old, for the person's [password]
/// and a [code]: shown once.
Future<List<String>> newRecoveryCodes({
required String password,
required String code,
}) async => await _gateway.newRecoveryCodes(
await _ownerAnswer(password, totpCode: code),
);
/// The owner's proof (`OwnerProof`): [password] answering a change's
/// challenge, with a [totpCode] or [recoveryCode].
Future<OwnerAnswer> _ownerAnswer(
String password, {
String? totpCode,
String? recoveryCode,
}) async {
final challenge = await _gateway.beginChange();
final unlock = await _crypto.unlock(
password,
salt: Uint8List.sublistView(challenge.salt),
costs: challenge.costs,
);
return (
challenge: challenge,
answer: await _crypto.answer(unlock, challenge, host: _gateway.host),
totpCode: totpCode?.replaceAll(' ', ''),
recoveryCode: recoveryCode,
);
}
/// What keeps the signed-in person's account.
Future<AccountSecurity> security() async {
@ -312,6 +353,7 @@ class PasswordRepository(
gitHub: ways.gitHub,
gitHubEnds: offers.gitHubEnds,
hasPassword: ways.password,
secondFactorWorks: ways.secondFactorWorks,
authenticators: ways.password
? await _gateway.authenticators()
: const <AccountAuthenticator>[],
@ -330,25 +372,15 @@ 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,
salt: Uint8List.sublistView(challenge.salt),
costs: challenge.costs,
);
final answer = await _crypto.answer(unlock, challenge, host: _gateway.host);
await _gateway.change(
challenge,
answer,
totpCode: totpCode?.replaceAll(' ', ''),
recoveryCode: recoveryCode,
await _ownerAnswer(
oldPassword,
totpCode: totpCode,
recoveryCode: recoveryCode,
),
password: await _crypto.newPassword(newPassword, keys),
address: address,
language: language,
);
_changed();
}
@ -365,9 +397,7 @@ class PasswordRepository(
String? recoveryCode,
String? request,
String? code,
required String language,
}) async {
final address = await _addressHere();
final challenge = await _gateway.beginChange();
final unlock = await _crypto.unlock(
password,
@ -381,23 +411,26 @@ class PasswordRepository(
recoveryCode: recoveryCode,
request: request,
code: code,
address: address,
language: language,
);
}
/// Mails a code for deleting the signed-in person's account to the
/// address this device signed in with, where no second factor works (a
/// setup never finished): that address, and the request the code
/// answers.
/// Mails a code for deleting the signed-in person's account to [address]
/// (the one this device signed in with where it knows it, [addressHere];
/// the sheet asks for it otherwise), where no second factor works: that
/// address, and the request the code answers. The instance takes the code
/// only for the account's own address.
Future<({String address, String request})> mailDeletionCode({
required String language,
String? address,
}) async {
final address = await _addressHere();
final to =
address?.trim() ??
await addressHere() ??
(throw const PasswordFailed(PasswordProblem.addressUnknown));
return (
address: address,
address: to,
request: await sendCode(
address,
to,
language: language,
purpose: MailCodePurpose.deletion,
),
@ -420,23 +453,11 @@ 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,
salt: Uint8List.sublistView(challenge.salt),
costs: challenge.costs,
);
await _gateway.setRecoveryKeys(
challenge,
await _crypto.answer(unlock, challenge, host: _gateway.host),
totpCode: code.replaceAll(' ', ''),
await _ownerAnswer(password, totpCode: code),
keys: phrase._recoveryKeys,
proofKey: phrase._recoveryProofKey,
address: address,
language: language,
);
_changed();
}
@ -593,12 +614,8 @@ class PasswordRepository(
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));
/// The address the signed-in person signed in with on this device, where
/// it knows it: where a code by mail goes (deleting an account whose
/// second factor doesn't work), which the sheet asks for otherwise.
Future<String?> addressHere() => _addresses.of(_signedIn());
}

View file

@ -143,14 +143,14 @@ class ServerpodPasswordGateway(
Future<List<String>> confirm(
String id,
String code, {
String? existingCode,
OwnerAnswer? owner,
SessionGrant? as,
}) => _as(
as,
(base) => base.authenticator.confirm(
authenticator: _idOf(id)!,
code: code,
existingCode: existingCode,
proof: _proofOf(owner),
),
);
@ -168,13 +168,31 @@ class ServerpodPasswordGateway(
);
@override
Future<void> remove(String id, {String? code}) => _failing(
() => _base.authenticator.remove(authenticator: _idOf(id)!, code: code),
Future<void> remove(String id, {OwnerAnswer? owner}) => _failing(
() => _base.authenticator.remove(
authenticator: _idOf(id)!,
proof: _proofOf(owner),
),
);
@override
Future<List<String>> newRecoveryCodes(String code) =>
_failing(() => _base.authenticator.newRecoveryCodes(code: code));
Future<List<String>> newRecoveryCodes(OwnerAnswer owner) => _failing(
() => _base.authenticator.newRecoveryCodes(proof: _proofOf(owner)!),
);
/// [owner] as the instance takes it, bound to the [host] addressed.
protocol.OwnerProof? _proofOf(OwnerAnswer? owner) => switch (owner) {
null => null,
(:final challenge, :final answer, :final totpCode, :final recoveryCode) =>
protocol.OwnerProof(
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
host: host,
totpCode: totpCode,
recoveryCode: recoveryCode,
),
};
@override
Future<PasswordWays> ways() => _failing(() async {
@ -184,6 +202,7 @@ class ServerpodPasswordGateway(
email: ways.email,
password: ways.password,
authenticator: ways.authenticator,
secondFactorWorks: ways.secondFactorWorks,
);
});
@ -199,8 +218,6 @@ class ServerpodPasswordGateway(
String? recoveryCode,
String? request,
String? code,
required String address,
required String language,
}) => _failing(() async {
await _base.account.delete(
proof: protocol.DeletionProof(
@ -212,8 +229,6 @@ class ServerpodPasswordGateway(
request: _idOf(request),
code: code,
host: host,
address: address,
language: language,
),
);
// The server ended every session of the person; forget this device's
@ -223,49 +238,22 @@ class ServerpodPasswordGateway(
@override
Future<void> change(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
OwnerAnswer owner, {
required protocol.NewPassword password,
required String address,
required String language,
}) => _failing(
() => _base.password.change(
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
password: password,
host: host,
address: address,
language: language,
),
() => _base.password.change(proof: _proofOf(owner)!, password: password),
);
@override
Future<void> setRecoveryKeys(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
OwnerAnswer owner, {
required protocol.WrappedKeys keys,
required ByteData proofKey,
required String address,
required String language,
}) => _failing(
() => _base.password.setRecoveryKeys(
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
proof: _proofOf(owner)!,
recoveryKeys: keys,
recoveryProofKey: proofKey,
host: host,
address: address,
language: language,
),
);
@ -454,8 +442,6 @@ PasswordProblem _problemOf(protocol.PasswordRefusal reason) => switch (reason) {
protocol.PasswordRefusal.wrongCode => PasswordProblem.wrongCode,
protocol.PasswordRefusal.notDue => PasswordProblem.notDue,
protocol.PasswordRefusal.noReset => PasswordProblem.noReset,
protocol.PasswordRefusal.wrongExistingCode =>
PasswordProblem.wrongExistingCode,
// The instance answers by another name than the device addressed it by:
// what the device proved is bound to that name, so nothing it says can
// be taken as the instance's.

View file

@ -19,6 +19,11 @@ final class const AccountSecurity({
/// Whether the person has a password.
required final bool hasPassword,
/// Whether a second factor works: an authenticator confirmed, and no
/// reset of them taken effect (as the instance tells it). Where none
/// does, deleting the account takes a code by mail.
final bool secondFactorWorks = false,
/// Their authenticators, the oldest first; empty without a password.
final List<AccountAuthenticator> authenticators = const [],
@ -36,6 +41,7 @@ final class const AccountSecurity({
gitHub,
gitHubEnds,
hasPassword,
secondFactorWorks,
authenticators,
keysHere,
];

View file

@ -41,8 +41,8 @@ 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.
/// The account's address, where a code by mail goes, is missing: type
/// it.
addressUnknown,
/// The account has no password yet.
@ -51,9 +51,6 @@ enum PasswordProblem() {
/// The authenticator's code isn't right.
wrongCode,
/// The code from an authenticator added before isn't right.
wrongExistingCode,
/// The last authenticator can't go: the second factor is mandatory.
lastAuthenticator,

View file

@ -240,13 +240,11 @@ 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;
})) {
@ -308,7 +306,7 @@ class _ChangePasswordState() extends State<_ChangePassword> {
}
}
/// Removes an authenticator: a confirmed one for a code.
/// Removes an authenticator: a confirmed one for the password and a code.
class const _RemoveAuthenticator(final AccountAuthenticator authenticator)
extends StatefulWidget {
@override
@ -316,11 +314,13 @@ class const _RemoveAuthenticator(final AccountAuthenticator authenticator)
}
class _RemoveAuthenticatorState() extends State<_RemoveAuthenticator> {
final _password = TextEditingController();
final _code = TextEditingController();
late final _step = SecurityStepCubit<bool>();
@override
void dispose() {
_password.dispose();
_code.dispose();
_step.close();
super.dispose();
@ -333,6 +333,7 @@ class _RemoveAuthenticatorState() extends State<_RemoveAuthenticator> {
if (await _step.run(() async {
await passwords.removeAuthenticator(
authenticator.id,
password: authenticator.confirmed ? _password.text : null,
code: authenticator.confirmed ? _code.text : null,
);
return true;
@ -354,10 +355,15 @@ class _RemoveAuthenticatorState() extends State<_RemoveAuthenticator> {
children: [
if (widget.authenticator.confirmed) ...[
SheetIntro(l10n.passwordSectionRemoveIntro),
PasswordField(
controller: _password,
label: l10n.passwordCurrent,
autofocus: true,
),
const SizedBox(height: 8),
CodeField(
controller: _code,
kind: CodeKind.totp,
autofocus: true,
errorText: _problemOf(context, state),
onSubmitted: (_) => _submit(),
),
@ -377,7 +383,8 @@ class _RemoveAuthenticatorState() extends State<_RemoveAuthenticator> {
}
/// Adds an authenticator: its QR code, its code and, where one is confirmed
/// already, a code from that one; the first one's recovery codes.
/// already, the password and a code from that one; the first one's recovery
/// codes.
class const _AddAuthenticator({
required final String label,
required final bool needsExistingCode,
@ -388,6 +395,7 @@ class const _AddAuthenticator({
class _AddAuthenticatorState() extends State<_AddAuthenticator> {
final _code = TextEditingController();
final _password = TextEditingController();
final _existing = TextEditingController();
late final AddAuthenticatorCubit _cubit;
@ -405,6 +413,7 @@ class _AddAuthenticatorState() extends State<_AddAuthenticator> {
@override
void dispose() {
_code.dispose();
_password.dispose();
_existing.dispose();
_cubit.close();
super.dispose();
@ -413,7 +422,11 @@ class _AddAuthenticatorState() extends State<_AddAuthenticator> {
void _submit(AddAuthenticatorState state) {
switch (state.step) {
case AddAuthenticatorStep.code:
_cubit.codeEntered(_code.text, existingCode: _existing.text);
_cubit.codeEntered(
_code.text,
password: _password.text,
existingCode: _existing.text,
);
case AddAuthenticatorStep.recoveryCodes ||
AddAuthenticatorStep.done ||
AddAuthenticatorStep.enrolling:
@ -449,16 +462,18 @@ class _AddAuthenticatorState() extends State<_AddAuthenticator> {
AddAuthenticatorStep.code => AuthenticatorSetupView(
setup: state.setup!,
code: _code,
password: widget.needsExistingCode ? _password : null,
existingCode: widget.needsExistingCode ? _existing : null,
enabled: !state.busy,
errorText:
state.problem?.problem == PasswordProblem.wrongExistingCode
? null
: problem,
existingErrorText:
state.problem?.problem == PasswordProblem.wrongExistingCode
// The new one's own code under it; the owner's proof (the
// password and the code from another) under that.
errorText: state.problem?.problem == PasswordProblem.wrongCode
? problem
: null,
existingErrorText:
state.problem?.problem == PasswordProblem.wrongCode
? null
: problem,
onDone: () => _submit(state),
),
AddAuthenticatorStep.recoveryCodes => Column(
@ -488,18 +503,20 @@ class _AddAuthenticatorState() extends State<_AddAuthenticator> {
}
}
/// New recovery codes for a code: shown once.
/// New recovery codes for the password and a code: shown once.
class const _NewRecoveryCodes() extends StatefulWidget {
@override
State<_NewRecoveryCodes> createState() => _NewRecoveryCodesState();
}
class _NewRecoveryCodesState() extends State<_NewRecoveryCodes> {
final _password = TextEditingController();
final _code = TextEditingController();
late final _step = SecurityStepCubit<List<String>>();
@override
void dispose() {
_password.dispose();
_code.dispose();
_step.close();
super.dispose();
@ -507,7 +524,12 @@ class _NewRecoveryCodesState() extends State<_NewRecoveryCodes> {
Future<void> _submit() async {
final passwords = _passwordsOf(context);
await _step.run(() => passwords.newRecoveryCodes(_code.text));
await _step.run(
() => passwords.newRecoveryCodes(
password: _password.text,
code: _code.text,
),
);
}
@override
@ -528,10 +550,15 @@ class _NewRecoveryCodesState() extends State<_NewRecoveryCodes> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SheetIntro(l10n.passwordNewCodesIntro),
PasswordField(
controller: _password,
label: l10n.passwordCurrent,
autofocus: true,
),
const SizedBox(height: 8),
CodeField(
controller: _code,
kind: CodeKind.totp,
autofocus: true,
errorText: _problemOf(context, state),
onSubmitted: (_) => _submit(),
),
@ -603,7 +630,6 @@ 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

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/app/app_scope.dart';
@ -10,9 +12,12 @@ import 'package:material_ui/material_ui.dart';
/// Deletes the signed-in person's account, which has a password: the
/// password and a code from an authenticator, as changing the password
/// takes (ADR 0017; the second reading), or [byMail], where no second
/// factor works (a setup never finished), the password and a code by mail
/// asked for deleting. Once deleted, the person is signed out here, and the
/// app asks how to start.
/// factor works (a setup never finished, or the authenticators' reset taken
/// effect: `AccountSecurity.secondFactorWorks`, as the instance tells it),
/// the password and a code by mail asked for deleting, to the address this
/// device signed in with, or one typed where it doesn't know it (the third
/// reading). Once deleted, the person is signed out here, and the app asks
/// how to start.
Future<void> showDeleteAccountWithPassword(
BuildContext context, {
bool byMail = false,
@ -31,8 +36,26 @@ class const _DeleteAccount({required final bool byMail})
class _DeleteAccountState() extends State<_DeleteAccount> {
final _password = TextEditingController();
final _code = TextEditingController();
final _address = TextEditingController();
late final _step = SecurityStepCubit<bool>();
/// Whether this device knows the account's address; null until read.
bool? _addressKnown;
@override
void initState() {
super.initState();
if (widget.byMail) {
unawaited(
AppScope.of(context).passwords.addressHere().then((address) {
if (mounted) {
setState(() => _addressKnown = address != null);
}
}),
);
}
}
/// The code mailed for deleting, once asked for: its address and request.
({String address, String request})? _mailed;
@ -40,6 +63,7 @@ class _DeleteAccountState() extends State<_DeleteAccount> {
void dispose() {
_password.dispose();
_code.dispose();
_address.dispose();
_step.close();
super.dispose();
}
@ -51,7 +75,10 @@ class _DeleteAccountState() extends State<_DeleteAccount> {
if (widget.byMail && mailed == null) {
// The code first; the password goes with it.
await _step.run(() async {
final asked = await passwords.mailDeletionCode(language: language);
final asked = await passwords.mailDeletionCode(
language: language,
address: (_addressKnown ?? false) ? null : _address.text,
);
setState(() => _mailed = asked);
return false;
});
@ -63,7 +90,6 @@ class _DeleteAccountState() extends State<_DeleteAccount> {
totpCode: widget.byMail ? null : _code.text,
request: mailed?.request,
code: widget.byMail ? _code.text : null,
language: language,
);
return true;
});
@ -95,6 +121,12 @@ class _DeleteAccountState() extends State<_DeleteAccount> {
autofocus: true,
),
const SizedBox(height: 8),
if (widget.byMail &&
_mailed == null &&
_addressKnown == false) ...[
AddressField(controller: _address),
const SizedBox(height: 8),
],
if (widget.byMail && _mailed == null)
if (state.problem case final failed?)
Text(

View file

@ -33,7 +33,6 @@ String passwordProblemText(BuildContext context, PasswordFailed failed) {
PasswordProblem.addressUnknown => l10n.passwordAddressUnknown,
PasswordProblem.noPassword => l10n.passwordNoPassword,
PasswordProblem.wrongCode => l10n.passwordWrongCode,
PasswordProblem.wrongExistingCode => l10n.passwordWrongExistingCode,
PasswordProblem.lastAuthenticator => l10n.passwordLastAuthenticator,
PasswordProblem.tooManyAuthenticators => l10n.passwordTooManyAuthenticators,
PasswordProblem.unknownAuthenticator => l10n.passwordUnknownAuthenticator,

View file

@ -482,10 +482,12 @@ class _QrPainter(final QrImage _code) extends CustomPainter {
/// What setting up an authenticator shows: the QR code of its URI, the key
/// to type by hand, on phones a button opening the authenticator app, and
/// the fields for its code (and one from an authenticator added before).
/// the fields for its code (and, once one is confirmed, the password and a
/// code from an authenticator added before: the owner's proof).
class const AuthenticatorSetupView({
required final AuthenticatorSetup setup,
required final TextEditingController code,
final TextEditingController? password,
final TextEditingController? existingCode,
final String? errorText,
final String? existingErrorText,
@ -566,6 +568,16 @@ class const AuthenticatorSetupView({
: TextInputAction.next,
onSubmitted: existingCode == null ? (_) => onDone?.call() : null,
),
if (password case final password?) ...[
const SizedBox(height: 16),
Text(l10n.passwordAuthenticatorOwnerIntro),
const SizedBox(height: 8),
PasswordField(
controller: password,
label: l10n.passwordCurrent,
enabled: enabled,
),
],
if (existingCode case final existing?) ...[
const SizedBox(height: 8),
CodeField(

View file

@ -446,20 +446,25 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
Future<List<String>> confirm(
String id,
String code, {
String? existingCode,
OwnerAnswer? owner,
SessionGrant? as,
}) => _call('confirm', () {
final account = _accountAs(as);
final index = account.authenticators.indexWhere((each) => each.id == id);
if (index < 0) {
if (index < 0 || account.authenticators[index].confirmed) {
throw const PasswordFailed(PasswordProblem.unknownAuthenticator);
}
if (code != fakeTotpCode) {
throw const PasswordFailed(PasswordProblem.wrongCode);
}
final first = !account.finished;
if (!first && existingCode != fakeTotpCode) {
throw const PasswordFailed(PasswordProblem.wrongExistingCode);
if (!first) {
if (owner == null) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
_ownerProved(owner);
} else if (owner != null) {
throw const PasswordFailed(PasswordProblem.invalidSetup);
}
final added = account.authenticators[index];
account.authenticators[index] = AccountAuthenticator(
@ -483,7 +488,7 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
_call('authenticators', () => [..._accountAs(null).authenticators]);
@override
Future<void> remove(String id, {String? code}) => _call('remove', () {
Future<void> remove(String id, {OwnerAnswer? owner}) => _call('remove', () {
final account = _accountAs(null);
final authenticator = account.authenticators
.where((each) => each.id == id)
@ -495,21 +500,21 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
if (account.authenticators.where((each) => each.confirmed).length < 2) {
throw const PasswordFailed(PasswordProblem.lastAuthenticator);
}
if (code != fakeTotpCode) {
throw const PasswordFailed(PasswordProblem.wrongCode);
if (owner == null) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
_ownerProved(owner);
} else if (owner != null) {
throw const PasswordFailed(PasswordProblem.invalidSetup);
}
account.authenticators.remove(authenticator);
});
@override
Future<List<String>> newRecoveryCodes(String code) => _call(
Future<List<String>> newRecoveryCodes(OwnerAnswer owner) => _call(
'newRecoveryCodes',
() {
final account = _accountAs(null);
if (code != fakeTotpCode) {
throw const PasswordFailed(PasswordProblem.wrongCode);
}
final account = _ownerProved(owner);
account.recoveryCodes
..clear()
..addAll([
@ -528,6 +533,9 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
email: account != null,
password: account?.password != null,
authenticator: account?.finished ?? false,
secondFactorWorks:
(account?.finished ?? false) &&
(account?.authenticatorReset?.isAfter(clock.now()) ?? true),
);
});
@ -548,21 +556,22 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
String? recoveryCode,
String? request,
String? code,
required String address,
required String language,
}) async {
final account = await _call('deleteAccount', () {
final proved = _proved(challenge, answer);
if (proved.finished) {
return _ownerCoded(proved, totpCode, recoveryCode, address);
}
// No second factor works: the password and a code by mail asked for
// deleting, as the register takes them.
if (proved.person != _signedIn()) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
_requireAddress(proved, address);
if (request == null ||
if ((totpCode ?? '').isNotEmpty || (recoveryCode ?? '').isNotEmpty) {
return _ownerCoded(proved, totpCode, recoveryCode);
}
// No second factor works: the password and a code by mail asked for
// deleting, as the register takes them.
final works =
proved.finished &&
(proved.authenticatorReset?.isAfter(clock.now()) ?? true);
if (works ||
request == null ||
(code ?? '').trim().isEmpty ||
_proven(request, code, MailCodePurpose.deletion) != proved.address) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
@ -575,65 +584,58 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
@override
Future<void> change(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
OwnerAnswer owner, {
required protocol.NewPassword password,
required String address,
required String language,
}) => _call('change', () {
_ownerProved(challenge, answer, totpCode, recoveryCode, address)
_ownerProved(owner)
..password = utf8.decode(Uint8List.sublistView(password.verifier))
..passwordKeys = password.passwordKeys;
..passwordKeys = password.passwordKeys
..codesWaitUntil = null;
});
/// The signed-in person's account, once [answer] to [challenge] proved
/// its password and a code came: refused as the register refuses a
/// change.
FakePasswordAccount _ownerProved(
protocol.PasswordChallenge challenge,
SrpAnswer answer,
String? totpCode,
String? recoveryCode,
String address,
) => _ownerCoded(_proved(challenge, answer), totpCode, recoveryCode, address);
/// The signed-in person's account, once the [owner]'s proof proved its
/// password and a code came: refused as the register refuses a change.
FakePasswordAccount _ownerProved(OwnerAnswer owner) => _ownerCoded(
_proved(owner.challenge, owner.answer),
owner.totpCode,
owner.recoveryCode,
);
/// [account], whose password proved right, once it is the signed-in
/// person's and a code came: refused as the register refuses a change.
/// person's and a code from it came: refused as the register refuses a
/// change. Never held back by the account's waits, as the register's
/// signed-in proofs aren't.
FakePasswordAccount _ownerCoded(
FakePasswordAccount account,
String? totpCode,
String? recoveryCode,
String address,
) {
if (account.person != _signedIn() ||
((totpCode ?? '').isEmpty && (recoveryCode ?? '').isEmpty)) {
if (account.person != _signedIn()) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
_requireAddress(account, address);
if (_secondFactor(
account,
totpCode: totpCode,
recoveryCode: recoveryCode,
)) {
if (!account.finished) {
throw const PasswordFailed(PasswordProblem.setupIncomplete);
}
final totp = totpCode?.trim() ?? '';
final recovery = recoveryCode?.trim().toUpperCase() ?? '';
if (totp.isNotEmpty
? totp != fakeTotpCode
: !account.recoveryCodes.remove(recovery)) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
account
..authenticatorReset = null
..accountReset = null;
return account;
}
@override
Future<void> setRecoveryKeys(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
OwnerAnswer owner, {
required protocol.WrappedKeys keys,
required ByteData proofKey,
required String address,
required String language,
}) => _call('setRecoveryKeys', () {
_ownerProved(challenge, answer, totpCode, recoveryCode, address)
_ownerProved(owner)
..recoveryKeys = keys
..recoveryProofKey = proofKey;
});

View file

@ -1,3 +1,4 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
@ -220,6 +221,53 @@ void main() {
);
});
testWidgets("deleting an account whose authenticators' reset took effect "
'asks for a code by mail, as the instance takes it, and for the '
"address where this device doesn't know it", (tester) async {
// The first person the fake password gateway makes.
const person = '00000000-0000-4000-8000-000000000001';
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: person, isIdentified: true),
);
await start(tester);
// Its authenticator still confirmed, the reset taken effect but not
// carried out (the third reading's fourth finding).
dependencies.passwordGateway
.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: dependencies.crypto,
)
.authenticatorReset = clock.now().subtract(
const Duration(hours: 1),
);
Finder field(String label) => find.widgetWithText(TextField, label);
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(find.textContaining('a code by mail confirms it'), findsOneWidget);
await tester.enterText(field('Current password'), 'correct horse');
await tester.enterText(field('Email address'), 'anna@example.org');
await tester.tap(find.text('Email me a code'));
await tester.pumpAndSettle();
expect(
dependencies.passwordGateway.mailed.single.purpose,
MailCodePurpose.deletion,
);
await tester.enterText(
field('Code from the email'),
dependencies.passwordGateway.codeFor('anna@example.org'),
);
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 1);
});
testWidgets('deleting an account with a password asks for it and a '
'code, which a wrong one fails, deleting nothing', (tester) async {
// The first person the fake password gateway makes.

View file

@ -61,6 +61,7 @@ void main() {
await passwords.confirm(
added.id,
fakeTotpCode,
password: 'correct horse',
existingCode: fakeTotpCode,
);
await pumpEventQueue();
@ -132,8 +133,9 @@ void main() {
.having((state) => state.busy, 'busy', busy)
.having((state) => state.problem?.problem, 'problem', problem);
test('takes its code and one from an authenticator added before, '
'refusing a wrong one, nothing added meanwhile', () async {
test("takes its code and the owner's proof (the password and a code "
'from an authenticator added before), refusing a wrong one, nothing '
'added meanwhile', () async {
final cubit = AddAuthenticatorCubit(
passwords,
label: 'anna@example.org',
@ -151,17 +153,37 @@ void main() {
expect(
await statesOf(
cubit,
() => cubit.codeEntered(fakeTotpCode, existingCode: '000000'),
() => cubit.codeEntered(
fakeTotpCode,
password: 'correct horse',
existingCode: '000000',
),
),
[
at(AddAuthenticatorStep.code, busy: true),
at(
AddAuthenticatorStep.code,
problem: PasswordProblem.wrongExistingCode,
problem: PasswordProblem.wrongCredentials,
),
],
);
await cubit.codeEntered('000000', existingCode: fakeTotpCode);
await cubit.codeEntered(
fakeTotpCode,
password: 'wrong',
existingCode: fakeTotpCode,
);
expect(
cubit.state,
at(
AddAuthenticatorStep.code,
problem: PasswordProblem.wrongCredentials,
),
);
await cubit.codeEntered(
'000000',
password: 'correct horse',
existingCode: fakeTotpCode,
);
expect(
cubit.state,
at(AddAuthenticatorStep.code, problem: PasswordProblem.wrongCode),
@ -171,7 +193,11 @@ void main() {
expect(
await statesOf(
cubit,
() => cubit.codeEntered(fakeTotpCode, existingCode: fakeTotpCode),
() => cubit.codeEntered(
fakeTotpCode,
password: 'correct horse',
existingCode: fakeTotpCode,
),
),
[
at(AddAuthenticatorStep.code, busy: true),
@ -264,7 +290,6 @@ 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.
@ -277,7 +302,6 @@ void main() {
wordsAsked(cubit.state),
password: 'wrong',
code: fakeTotpCode,
language: 'en',
),
),
[
@ -289,7 +313,6 @@ void main() {
wordsAsked(cubit.state),
password: 'correct horse',
code: '000000',
language: 'en',
);
expect(
cubit.state,
@ -304,7 +327,6 @@ void main() {
wordsAsked(cubit.state),
password: 'correct horse',
code: fakeTotpCode,
language: 'en',
),
),
[

View file

@ -194,7 +194,7 @@ void main() {
await settle();
await expectLater(
passwords.deleteAccount(password: 'battery staple', language: 'en'),
passwords.deleteAccount(password: 'battery staple'),
failsWith(PasswordProblem.wrongCredentials),
);
final mailed = await passwords.mailDeletionCode(language: 'en');
@ -204,7 +204,6 @@ void main() {
password: 'battery staple',
request: mailed.request,
code: gateway.codeFor('carla@example.org'),
language: 'en',
);
expect(gateway.accounts, isNot(contains('carla@example.org')));
});
@ -317,7 +316,6 @@ void main() {
oldPassword: 'correct horse',
newPassword: 'better horse',
totpCode: fakeTotpCode,
language: 'en',
);
expect(anna.password, 'better horse');
@ -340,7 +338,6 @@ void main() {
oldPassword: 'wrong',
newPassword: 'better horse',
totpCode: fakeTotpCode,
language: 'en',
),
failsWith(PasswordProblem.wrongCredentials),
);
@ -350,44 +347,36 @@ 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 {
test("takes no address while signed in: a device that doesn't know it "
'changes the password, and asks for one only to mail a code for '
'deleting', () async {
addresses.addresses.clear();
await passwords.changePassword(
oldPassword: 'correct horse',
newPassword: 'better horse',
newPassword: 'better horses',
totpCode: fakeTotpCode,
language: 'en',
);
expect(anna.password, 'better horse');
expect(anna.password, 'better horses');
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',
),
passwords.mailDeletionCode(language: 'en'),
failsWith(PasswordProblem.addressUnknown),
);
expect(gateway.calls, hasLength(calls));
expect(anna.password, 'better horse');
expect(
(await passwords.mailDeletionCode(
language: 'en',
address: ' anna@example.org ',
)).address,
'anna@example.org',
);
});
test('deletes the account with the password and a code alone, then '
@ -396,12 +385,11 @@ void main() {
passwords.deleteAccount(
password: 'wrong',
totpCode: fakeTotpCode,
language: 'en',
),
failsWith(PasswordProblem.wrongCredentials),
);
await expectLater(
passwords.deleteAccount(password: 'correct horse', language: 'en'),
passwords.deleteAccount(password: 'correct horse'),
failsWith(PasswordProblem.wrongCredentials),
);
expect(gateway.accounts, contains('anna@example.org'));
@ -410,7 +398,6 @@ void main() {
await passwords.deleteAccount(
password: 'correct horse',
totpCode: fakeTotpCode,
language: 'en',
);
expect(gateway.accounts, isNot(contains('anna@example.org')));
@ -426,7 +413,6 @@ void main() {
phrase,
password: 'correct horse',
code: '000000',
language: 'en',
),
failsWith(PasswordProblem.wrongCredentials),
);
@ -435,7 +421,6 @@ void main() {
phrase,
password: 'wrong',
code: fakeTotpCode,
language: 'en',
),
failsWith(PasswordProblem.wrongCredentials),
);
@ -445,7 +430,6 @@ void main() {
phrase,
password: 'correct horse',
code: fakeTotpCode,
language: 'en',
);
await auth.signOut();

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