feat(app): a lost password with the recovery phrase in SimpleLogin's sheet (ADR 0020, 7)
An account that signs in with SimpleLogin opens its keys with its password. Where that is lost, the unlock step's "Forgot your password?" opens one step: the recovery phrase, and a new password twice. There is no address and no code: the SimpleLogin sign-in of a moment ago stands for them (D20). - The device opens the keys with the phrase (`keys.beginPhrase`) and wraps them under the new password (`AccountCrypto.newKeysPassword`). It then signs the phrase's proof bound to that password (`signKeysResetProof`), which `keys.resetPassword` takes with the sign-in. The keys stay, and every other session ends. - A phrase that doesn't open the keys is refused on the device, and the instance gets no proof to count. Two passwords that differ are refused before anything is sent. - The gateway's `beginPhrase` and `resetPassword` run on the account's own session, not yet the device's. The repository's `resetPassword` ties them together, and the cubit and sheet have their `forgot` step. Three texts are new, in English and German. - halkyon_base_server exports `isKeysPassword` for the interop test, which checks against the register's checks, with real libsodium, what the device sends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
904b2e41b2
commit
9e9f5bf406
22 changed files with 756 additions and 2 deletions
|
|
@ -107,6 +107,12 @@
|
||||||
`removeProfile`, members asking it) empties the display name, bio, links and their
|
`removeProfile`, members asking it) empties the display name, bio, links and their
|
||||||
checks and the avatar, keeps the row with its handle, and returns what is left; Learn's
|
checks and the avatar, keeps the row with its handle, and returns what is left; Learn's
|
||||||
profile form starts afresh from it and offers no removing while nothing is filled in.
|
profile form starts afresh from it and offers no removing while nothing is filled in.
|
||||||
|
Then a lost password with the phrase (7, D20), in SimpleLogin's sheet: the unlock
|
||||||
|
step's Forgot your password? opens one step, the recovery phrase and a new password
|
||||||
|
twice, with no address or code, as the sign-in of a moment ago stands for them. The
|
||||||
|
device opens the keys with the phrase, wraps them under the new password and signs the
|
||||||
|
phrase's proof bound to it (`keys.beginPhrase`, `keys.resetPassword`); the keys stay,
|
||||||
|
every other session ends.
|
||||||
- **Builds on:** ADR 0010 (email sign-in), ADR 0011 (profile pages), ADR 0016 (the
|
- **Builds on:** ADR 0010 (email sign-in), ADR 0011 (profile pages), ADR 0016 (the
|
||||||
register), ADR 0017 (a password, TOTP and the keys).
|
register), ADR 0017 (a password, TOTP and the keys).
|
||||||
- **Changes:** ADR 0017's way in on an instance that offers SimpleLogin (its keys, their
|
- **Changes:** ADR 0017's way in on an instance that offers SimpleLogin (its keys, their
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,9 @@
|
||||||
"simpleLoginMend": "Verknüpfen",
|
"simpleLoginMend": "Verknüpfen",
|
||||||
"simpleLoginWrongPassword": "Das Passwort stimmt nicht.",
|
"simpleLoginWrongPassword": "Das Passwort stimmt nicht.",
|
||||||
"simpleLoginMendDoesNotFit": "Das Handle oder die Wiederherstellungsphrase stimmt nicht.",
|
"simpleLoginMendDoesNotFit": "Das Handle oder die Wiederherstellungsphrase stimmt nicht.",
|
||||||
|
"simpleLoginForgotTitle": "Ein neues Passwort",
|
||||||
|
"simpleLoginForgotIntro": "Deine Wiederherstellungsphrase öffnet deine Schlüssel, die dann dein neues Passwort schützt. Alles bleibt; deine anderen Geräte werden abgemeldet.",
|
||||||
|
"simpleLoginForgotDoesNotFit": "Die Wiederherstellungsphrase stimmt nicht.",
|
||||||
"simpleLoginUnavailable": "Diese Instanz bietet die Anmeldung mit SimpleLogin nicht an.",
|
"simpleLoginUnavailable": "Diese Instanz bietet die Anmeldung mit SimpleLogin nicht an.",
|
||||||
"simpleLoginDenied": "Die Anmeldung bei SimpleLogin wurde abgelehnt, also wurde nichts geteilt.",
|
"simpleLoginDenied": "Die Anmeldung bei SimpleLogin wurde abgelehnt, also wurde nichts geteilt.",
|
||||||
"simpleLoginNoBrowser": "Es gibt keinen Browser, der die Seite von SimpleLogin öffnen kann.",
|
"simpleLoginNoBrowser": "Es gibt keinen Browser, der die Seite von SimpleLogin öffnen kann.",
|
||||||
|
|
|
||||||
|
|
@ -597,6 +597,9 @@
|
||||||
"simpleLoginMend": "Link",
|
"simpleLoginMend": "Link",
|
||||||
"simpleLoginWrongPassword": "The password isn't right.",
|
"simpleLoginWrongPassword": "The password isn't right.",
|
||||||
"simpleLoginMendDoesNotFit": "The handle or the recovery phrase isn't right.",
|
"simpleLoginMendDoesNotFit": "The handle or the recovery phrase isn't right.",
|
||||||
|
"simpleLoginForgotTitle": "A new password",
|
||||||
|
"simpleLoginForgotIntro": "Your recovery phrase opens your keys, which your new password then protects. Everything stays; your other devices are signed out.",
|
||||||
|
"simpleLoginForgotDoesNotFit": "The recovery phrase isn't right.",
|
||||||
"simpleLoginUnavailable": "This instance doesn't offer sign-in with SimpleLogin.",
|
"simpleLoginUnavailable": "This instance doesn't offer sign-in with SimpleLogin.",
|
||||||
"simpleLoginDenied": "SimpleLogin's sign-in was declined, so nothing was shared.",
|
"simpleLoginDenied": "SimpleLogin's sign-in was declined, so nothing was shared.",
|
||||||
"simpleLoginNoBrowser": "There's no browser to open SimpleLogin's page in.",
|
"simpleLoginNoBrowser": "There's no browser to open SimpleLogin's page in.",
|
||||||
|
|
|
||||||
|
|
@ -1681,6 +1681,24 @@ abstract class HalkyonAppLocalizations {
|
||||||
/// **'The handle or the recovery phrase isn\'t right.'**
|
/// **'The handle or the recovery phrase isn\'t right.'**
|
||||||
String get simpleLoginMendDoesNotFit;
|
String get simpleLoginMendDoesNotFit;
|
||||||
|
|
||||||
|
/// No description provided for @simpleLoginForgotTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'A new password'**
|
||||||
|
String get simpleLoginForgotTitle;
|
||||||
|
|
||||||
|
/// No description provided for @simpleLoginForgotIntro.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your recovery phrase opens your keys, which your new password then protects. Everything stays; your other devices are signed out.'**
|
||||||
|
String get simpleLoginForgotIntro;
|
||||||
|
|
||||||
|
/// No description provided for @simpleLoginForgotDoesNotFit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The recovery phrase isn\'t right.'**
|
||||||
|
String get simpleLoginForgotDoesNotFit;
|
||||||
|
|
||||||
/// No description provided for @simpleLoginUnavailable.
|
/// No description provided for @simpleLoginUnavailable.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -991,6 +991,17 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
||||||
String get simpleLoginMendDoesNotFit =>
|
String get simpleLoginMendDoesNotFit =>
|
||||||
'Das Handle oder die Wiederherstellungsphrase stimmt nicht.';
|
'Das Handle oder die Wiederherstellungsphrase stimmt nicht.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get simpleLoginForgotTitle => 'Ein neues Passwort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get simpleLoginForgotIntro =>
|
||||||
|
'Deine Wiederherstellungsphrase öffnet deine Schlüssel, die dann dein neues Passwort schützt. Alles bleibt; deine anderen Geräte werden abgemeldet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get simpleLoginForgotDoesNotFit =>
|
||||||
|
'Die Wiederherstellungsphrase stimmt nicht.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get simpleLoginUnavailable =>
|
String get simpleLoginUnavailable =>
|
||||||
'Diese Instanz bietet die Anmeldung mit SimpleLogin nicht an.';
|
'Diese Instanz bietet die Anmeldung mit SimpleLogin nicht an.';
|
||||||
|
|
|
||||||
|
|
@ -973,6 +973,16 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
||||||
String get simpleLoginMendDoesNotFit =>
|
String get simpleLoginMendDoesNotFit =>
|
||||||
'The handle or the recovery phrase isn\'t right.';
|
'The handle or the recovery phrase isn\'t right.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get simpleLoginForgotTitle => 'A new password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get simpleLoginForgotIntro =>
|
||||||
|
'Your recovery phrase opens your keys, which your new password then protects. Everything stays; your other devices are signed out.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get simpleLoginForgotDoesNotFit => 'The recovery phrase isn\'t right.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get simpleLoginUnavailable =>
|
String get simpleLoginUnavailable =>
|
||||||
'This instance doesn\'t offer sign-in with SimpleLogin.';
|
'This instance doesn\'t offer sign-in with SimpleLogin.';
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,27 @@ abstract interface class AccountCrypto() {
|
||||||
required String instanceUrl,
|
required String instanceUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// A new password for the keys of an account that signs in with
|
||||||
|
/// SimpleLogin ([keys], which stay): the salt, costs, the public half of
|
||||||
|
/// its proof key and the keys wrapped under it, as [newPassword] without
|
||||||
|
/// SRP's verifier (ADR 0020, D19).
|
||||||
|
Future<protocol.KeysPassword> newKeysPassword(
|
||||||
|
String password,
|
||||||
|
UnlockedAccountKeys keys,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The signature proving the recovery [phrase] for [challenge] on
|
||||||
|
/// [instanceUrl], for the new keys [password] it sets in place of a lost
|
||||||
|
/// one (`KeyProofPurpose.passwordReset`): by the phrase's own proof key,
|
||||||
|
/// as [signKeyProof]'s. Refused (`PasswordProblem.notAPhrase`) for what
|
||||||
|
/// is no recovery phrase.
|
||||||
|
Future<ByteData> signKeysResetProof(
|
||||||
|
String phrase,
|
||||||
|
protocol.KeyProofChallenge challenge, {
|
||||||
|
required String instanceUrl,
|
||||||
|
required protocol.KeysPassword password,
|
||||||
|
});
|
||||||
|
|
||||||
/// The signature proving the recovery [phrase] for [challenge] on
|
/// The signature proving the recovery [phrase] for [challenge] on
|
||||||
/// [instanceUrl], to link the SimpleLogin sign-in under [attempt] in
|
/// [instanceUrl], to link the SimpleLogin sign-in under [attempt] in
|
||||||
/// place of a lost one (ADR 0020, D7; `KeyProofPurpose.mendLink`): by the
|
/// place of a lost one (ADR 0020, D7; `KeyProofPurpose.mendLink`): by the
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,19 @@ Uint8List newPasswordDigestOf(protocol.NewPassword password) =>
|
||||||
keysCipherText: Uint8List.sublistView(password.passwordKeys.cipherText),
|
keysCipherText: Uint8List.sublistView(password.passwordKeys.cipherText),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// The digest of a new [password] for the keys of an account that signs in
|
||||||
|
/// with SimpleLogin (its salt, costs, proof key and wrapping).
|
||||||
|
Uint8List newKeysPasswordDigestOf(protocol.KeysPassword password) =>
|
||||||
|
srp.newKeysPasswordDigest(
|
||||||
|
salt: Uint8List.sublistView(password.salt),
|
||||||
|
algorithm: password.costs.algorithm,
|
||||||
|
opsLimit: password.costs.opsLimit,
|
||||||
|
memLimit: password.costs.memLimit,
|
||||||
|
proofKey: Uint8List.sublistView(password.passwordProofKey),
|
||||||
|
keysNonce: Uint8List.sublistView(password.passwordKeys.nonce),
|
||||||
|
keysCipherText: Uint8List.sublistView(password.passwordKeys.cipherText),
|
||||||
|
);
|
||||||
|
|
||||||
/// The digest of a new recovery phrase: the keys wrapped under it
|
/// The digest of a new recovery phrase: the keys wrapped under it
|
||||||
/// ([recoveryKeys]) and its proof key's public half ([proofKey]).
|
/// ([recoveryKeys]) and its proof key's public half ([proofKey]).
|
||||||
Uint8List newPhraseDigestOf(
|
Uint8List newPhraseDigestOf(
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,74 @@ class SodiumAccountCrypto() implements AccountCrypto {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<protocol.KeysPassword> newKeysPassword(
|
||||||
|
String password,
|
||||||
|
UnlockedAccountKeys keys,
|
||||||
|
) => Isolate.run(
|
||||||
|
() => _guarded(() async {
|
||||||
|
checkNewPassword(password);
|
||||||
|
final sodium = await SodiumSumoInit.init();
|
||||||
|
final opened = _keysOf(sodium, keys);
|
||||||
|
final salt = _own(newPasswordSalt(sodium));
|
||||||
|
final unlocked = derivePasswordKeys(
|
||||||
|
sodium,
|
||||||
|
password: password,
|
||||||
|
salt: salt,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return protocol.KeysPassword(
|
||||||
|
salt: ByteData.sublistView(salt),
|
||||||
|
costs: _costsOf(PasswordKdf.current),
|
||||||
|
// Copied by proofPublicKey already.
|
||||||
|
passwordProofKey: ByteData.sublistView(unlocked.proofPublicKey),
|
||||||
|
passwordKeys: _wrappedOf(
|
||||||
|
wrapAccountKeys(sodium, opened, unlocked.keyEncryptionKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
unlocked.dispose();
|
||||||
|
opened.dispose();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ByteData> signKeysResetProof(
|
||||||
|
String phrase,
|
||||||
|
protocol.KeyProofChallenge challenge, {
|
||||||
|
required String instanceUrl,
|
||||||
|
required protocol.KeysPassword password,
|
||||||
|
}) => Isolate.run(
|
||||||
|
() => _guarded(() async {
|
||||||
|
final sodium = await SodiumSumoInit.init();
|
||||||
|
final KeyPair proof;
|
||||||
|
try {
|
||||||
|
proof = recoveryProofKeysOf(sodium, phrase);
|
||||||
|
} on KeyRefused {
|
||||||
|
throw const PasswordFailed(PasswordProblem.notAPhrase);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return ByteData.sublistView(
|
||||||
|
_own(
|
||||||
|
sodium.crypto.sign.detached(
|
||||||
|
message: srp.keyProofMessage(
|
||||||
|
challenge.challenge.toBytes(),
|
||||||
|
Uint8List.sublistView(challenge.nonce),
|
||||||
|
instanceUrl: instanceUrl,
|
||||||
|
purpose: srp.KeyProofPurpose.passwordReset,
|
||||||
|
digest: newKeysPasswordDigestOf(password),
|
||||||
|
),
|
||||||
|
secretKey: proof.secretKey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
proof.secretKey.dispose();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ByteData> signMendProof(
|
Future<ByteData> signMendProof(
|
||||||
String phrase,
|
String phrase,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ enum SimpleLoginStep() {
|
||||||
/// The password that opens the account's keys.
|
/// The password that opens the account's keys.
|
||||||
unlock,
|
unlock,
|
||||||
|
|
||||||
|
/// The recovery phrase and a new password, twice, for a password lost.
|
||||||
|
forgot,
|
||||||
|
|
||||||
/// The handle and the recovery phrase, which link the account again.
|
/// The handle and the recovery phrase, which link the account again.
|
||||||
mend,
|
mend,
|
||||||
|
|
||||||
|
|
@ -111,8 +114,9 @@ final class const SimpleLoginState({
|
||||||
/// browser, then, for someone new, an account created (a handle, a password
|
/// browser, then, for someone new, an account created (a handle, a password
|
||||||
/// twice, the recovery phrase shown once with three of its words typed
|
/// twice, the recovery phrase shown once with three of its words typed
|
||||||
/// back) or theirs linked again with its handle and recovery phrase (D7);
|
/// back) or theirs linked again with its handle and recovery phrase (D7);
|
||||||
/// for an account, its password, which opens its keys, or, for one without
|
/// for an account, its password, which opens its keys (or, for one lost, the
|
||||||
/// keys, the steps making them. With [_link], SimpleLogin is added to the
|
/// recovery phrase and a new password), or, for one without keys, the steps
|
||||||
|
/// making them. With [_link], SimpleLogin is added to the
|
||||||
/// account of the device's person instead, who keeps everything. Nothing is
|
/// account of the device's person instead, who keeps everything. Nothing is
|
||||||
/// the device's until the last step: no account exists whose phrase was
|
/// the device's until the last step: no account exists whose phrase was
|
||||||
/// never written down.
|
/// never written down.
|
||||||
|
|
@ -308,6 +312,43 @@ class SimpleLoginCubit(
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The password is lost: the recovery phrase and a new one instead.
|
||||||
|
void forgotChosen() => emit(
|
||||||
|
state.copyWith(
|
||||||
|
step: SimpleLoginStep.forgot,
|
||||||
|
passwordsDiffer: false,
|
||||||
|
clearProblem: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Back to the password.
|
||||||
|
void unlockShownAgain() => emit(
|
||||||
|
state.copyWith(step: SimpleLoginStep.unlock, clearProblem: true),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The recovery phrase and the new password, typed twice: the keys the
|
||||||
|
/// phrase opens wrapped under it, and the account signed in.
|
||||||
|
Future<void> forgotEntered(
|
||||||
|
String phrase,
|
||||||
|
String password,
|
||||||
|
String again,
|
||||||
|
) async {
|
||||||
|
if (password != again) {
|
||||||
|
emit(state.copyWith(passwordsDiffer: true, clearProblem: true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(state.copyWith(passwordsDiffer: false));
|
||||||
|
await _run(() async {
|
||||||
|
await _enter(
|
||||||
|
await _simpleLogin.resetPassword(
|
||||||
|
_arrival! as SimpleLoginAccount,
|
||||||
|
phrase: phrase,
|
||||||
|
newPassword: password,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// The handle and the recovery phrase: the account linked again.
|
/// The handle and the recovery phrase: the account linked again.
|
||||||
Future<void> mendEntered(String handle, String phrase) => _run(() async {
|
Future<void> mendEntered(String handle, String phrase) => _run(() async {
|
||||||
final typed = handleOf(handle);
|
final typed = handleOf(handle);
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,22 @@ class ServerpodSimpleLoginGateway(
|
||||||
() => _calls.asGrant(as, (base) => base.keys.unlock(proof)),
|
() => _calls.asGrant(as, (base) => base.keys.unlock(proof)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<protocol.KeyProofChallenge> beginPhrase({SessionGrant? as}) =>
|
||||||
|
_failing(() => _calls.asGrant(as, (base) => base.keys.beginPhrase()));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> resetPassword(
|
||||||
|
protocol.KeysOwnerProof proof,
|
||||||
|
protocol.KeysPassword password, {
|
||||||
|
SessionGrant? as,
|
||||||
|
}) => _failing(
|
||||||
|
() => _calls.asGrant(
|
||||||
|
as,
|
||||||
|
(base) => base.keys.resetPassword(proof: proof, password: password),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setKeys(
|
Future<void> setKeys(
|
||||||
protocol.UuidValue attempt,
|
protocol.UuidValue attempt,
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,20 @@ abstract interface class SimpleLoginGateway() {
|
||||||
SessionGrant? as,
|
SessionGrant? as,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The first step of proving the account's recovery phrase, for a lost
|
||||||
|
/// password: the keys wrapped under the phrase, and a nonce to sign with
|
||||||
|
/// its proof key.
|
||||||
|
Future<protocol.KeyProofChallenge> beginPhrase({SessionGrant? as});
|
||||||
|
|
||||||
|
/// Sets [password] in place of the lost one with the phrase's [proof]
|
||||||
|
/// and the SimpleLogin sign-in under its attempt of the last ten
|
||||||
|
/// minutes: the keys stay, and every other session of the account ends.
|
||||||
|
Future<void> resetPassword(
|
||||||
|
protocol.KeysOwnerProof proof,
|
||||||
|
protocol.KeysPassword password, {
|
||||||
|
SessionGrant? as,
|
||||||
|
});
|
||||||
|
|
||||||
/// Gives an account without keys the keys [setup] describes, and
|
/// Gives an account without keys the keys [setup] describes, and
|
||||||
/// [handle] where it has none, with its SimpleLogin sign-in under
|
/// [handle] where it has none, with its SimpleLogin sign-in under
|
||||||
/// [attempt] of the last ten minutes.
|
/// [attempt] of the last ten minutes.
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,45 @@ class SimpleLoginRepository(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets [newPassword] for [account]'s lost one with its recovery
|
||||||
|
/// [phrase] (ADR 0020, 7), the keys staying: the phrase opens them on the
|
||||||
|
/// device first, so one that doesn't fit spends none of the instance's
|
||||||
|
/// tries, and its proof binds the new password and the sign-in. Every
|
||||||
|
/// other session of the account ends.
|
||||||
|
Future<PendingSimpleLogin> resetPassword(
|
||||||
|
SimpleLoginAccount account, {
|
||||||
|
required String phrase,
|
||||||
|
required String newPassword,
|
||||||
|
}) async {
|
||||||
|
final challenge = await _gateway.beginPhrase(as: account.grant);
|
||||||
|
final keys = await _crypto.openWithPhrase(
|
||||||
|
phrase,
|
||||||
|
challenge.recoveryKeys,
|
||||||
|
encryptionPublicKey: Uint8List.sublistView(
|
||||||
|
challenge.encryptionPublicKey,
|
||||||
|
),
|
||||||
|
signingPublicKey: Uint8List.sublistView(challenge.signingPublicKey),
|
||||||
|
);
|
||||||
|
final password = await _crypto.newKeysPassword(newPassword, keys);
|
||||||
|
final signature = await _crypto.signKeysResetProof(
|
||||||
|
phrase,
|
||||||
|
challenge,
|
||||||
|
instanceUrl: _gateway.instanceUrl,
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
await _gateway.resetPassword(
|
||||||
|
protocol.KeysOwnerProof(
|
||||||
|
challenge: challenge.challenge,
|
||||||
|
signature: signature,
|
||||||
|
instanceUrl: _gateway.instanceUrl,
|
||||||
|
attempt: account.attempt,
|
||||||
|
),
|
||||||
|
password,
|
||||||
|
as: account.grant,
|
||||||
|
);
|
||||||
|
return PendingSimpleLogin(grant: account.grant, keys: keys);
|
||||||
|
}
|
||||||
|
|
||||||
/// Links the SimpleLogin account of someone new here ([newcomer]) to the
|
/// Links the SimpleLogin account of someone new here ([newcomer]) to the
|
||||||
/// account with [handle] in place of a lost one (D7), by its recovery
|
/// account with [handle] in place of a lost one (D7), by its recovery
|
||||||
/// [phrase], which opens the keys on the device first: a phrase that
|
/// [phrase], which opens the keys on the device first: a phrase that
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
||||||
import 'package:halkyon_app/src/app/app_scope.dart';
|
import 'package:halkyon_app/src/app/app_scope.dart';
|
||||||
import 'package:halkyon_app/src/handle/bloc/handle_check_cubit.dart';
|
import 'package:halkyon_app/src/handle/bloc/handle_check_cubit.dart';
|
||||||
import 'package:halkyon_app/src/handle/widget/handle_field.dart';
|
import 'package:halkyon_app/src/handle/widget/handle_field.dart';
|
||||||
|
import 'package:halkyon_app/src/password/model/password_failed.dart';
|
||||||
import 'package:halkyon_app/src/password/widget/password_widgets.dart';
|
import 'package:halkyon_app/src/password/widget/password_widgets.dart';
|
||||||
import 'package:halkyon_app/src/simplelogin/bloc/simplelogin_cubit.dart';
|
import 'package:halkyon_app/src/simplelogin/bloc/simplelogin_cubit.dart';
|
||||||
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_texts.dart';
|
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_texts.dart';
|
||||||
|
|
@ -103,6 +104,8 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
|
||||||
_cubit.phraseChecked([for (final word in _words) word.text]);
|
_cubit.phraseChecked([for (final word in _words) word.text]);
|
||||||
case SimpleLoginStep.unlock:
|
case SimpleLoginStep.unlock:
|
||||||
_cubit.unlockEntered(_password.text);
|
_cubit.unlockEntered(_password.text);
|
||||||
|
case SimpleLoginStep.forgot:
|
||||||
|
_cubit.forgotEntered(_phrase.text, _password.text, _again.text);
|
||||||
case SimpleLoginStep.mend:
|
case SimpleLoginStep.mend:
|
||||||
_cubit.mendEntered(_handle.text, _phrase.text);
|
_cubit.mendEntered(_handle.text, _phrase.text);
|
||||||
case SimpleLoginStep.newcomer ||
|
case SimpleLoginStep.newcomer ||
|
||||||
|
|
@ -124,6 +127,10 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
|
||||||
case SimpleLoginStep.password || SimpleLoginStep.unlock:
|
case SimpleLoginStep.password || SimpleLoginStep.unlock:
|
||||||
_password.clear();
|
_password.clear();
|
||||||
_again.clear();
|
_again.clear();
|
||||||
|
case SimpleLoginStep.forgot:
|
||||||
|
_phrase.clear();
|
||||||
|
_password.clear();
|
||||||
|
_again.clear();
|
||||||
case SimpleLoginStep.phraseCheck:
|
case SimpleLoginStep.phraseCheck:
|
||||||
for (final word in _words) {
|
for (final word in _words) {
|
||||||
word.clear();
|
word.clear();
|
||||||
|
|
@ -147,6 +154,13 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
|
||||||
listener: _stepChanged,
|
listener: _stepChanged,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final problem = switch (state.problem) {
|
final problem = switch (state.problem) {
|
||||||
|
// The phrase alone is asked for here, with no handle.
|
||||||
|
PasswordFailed(
|
||||||
|
problem: PasswordProblem.phraseDoesNotFit ||
|
||||||
|
PasswordProblem.wrongCredentials,
|
||||||
|
)
|
||||||
|
when state.step == SimpleLoginStep.forgot =>
|
||||||
|
l10n.simpleLoginForgotDoesNotFit,
|
||||||
final failed? => simpleLoginFailureText(context, failed),
|
final failed? => simpleLoginFailureText(context, failed),
|
||||||
null => null,
|
null => null,
|
||||||
};
|
};
|
||||||
|
|
@ -164,6 +178,7 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
|
||||||
SimpleLoginStep.phrase => l10n.passwordPhraseTitle,
|
SimpleLoginStep.phrase => l10n.passwordPhraseTitle,
|
||||||
SimpleLoginStep.phraseCheck => l10n.passwordPhraseCheckTitle,
|
SimpleLoginStep.phraseCheck => l10n.passwordPhraseCheckTitle,
|
||||||
SimpleLoginStep.unlock => l10n.simpleLoginUnlockTitle,
|
SimpleLoginStep.unlock => l10n.simpleLoginUnlockTitle,
|
||||||
|
SimpleLoginStep.forgot => l10n.simpleLoginForgotTitle,
|
||||||
SimpleLoginStep.mend => l10n.simpleLoginMendTitle,
|
SimpleLoginStep.mend => l10n.simpleLoginMendTitle,
|
||||||
_ when widget.link => l10n.simpleLoginAddTitle,
|
_ when widget.link => l10n.simpleLoginAddTitle,
|
||||||
_ => l10n.simpleLoginTitle,
|
_ => l10n.simpleLoginTitle,
|
||||||
|
|
@ -197,6 +212,7 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
|
||||||
SimpleLoginStep.phraseCheck when state.newcomer =>
|
SimpleLoginStep.phraseCheck when state.newcomer =>
|
||||||
l10n.simpleLoginCreate,
|
l10n.simpleLoginCreate,
|
||||||
SimpleLoginStep.unlock => l10n.simpleLoginOpen,
|
SimpleLoginStep.unlock => l10n.simpleLoginOpen,
|
||||||
|
SimpleLoginStep.forgot => l10n.passwordSignInAction,
|
||||||
SimpleLoginStep.mend => l10n.simpleLoginMend,
|
SimpleLoginStep.mend => l10n.simpleLoginMend,
|
||||||
_ => l10n.passwordContinue,
|
_ => l10n.passwordContinue,
|
||||||
}),
|
}),
|
||||||
|
|
@ -325,6 +341,54 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
|
||||||
onSubmitted: (_) => _submit(state),
|
onSubmitted: (_) => _submit(state),
|
||||||
errorText: problem,
|
errorText: problem,
|
||||||
),
|
),
|
||||||
|
Align(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: enabled ? _cubit.forgotChosen : null,
|
||||||
|
child: Text(l10n.passwordForgot),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SimpleLoginStep.forgot => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
SheetIntro(l10n.simpleLoginForgotIntro),
|
||||||
|
PhraseField(
|
||||||
|
controller: _phrase,
|
||||||
|
enabled: enabled,
|
||||||
|
autofocus: true,
|
||||||
|
errorText: _phraseProblem(state.problem) ? problem : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
PasswordField(
|
||||||
|
controller: _password,
|
||||||
|
label: l10n.passwordNewPassword,
|
||||||
|
showsMinimum: true,
|
||||||
|
isNew: true,
|
||||||
|
enabled: enabled,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
PasswordField(
|
||||||
|
controller: _again,
|
||||||
|
label: l10n.passwordRepeat,
|
||||||
|
isNew: true,
|
||||||
|
enabled: enabled,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _submit(state),
|
||||||
|
errorText: state.passwordsDiffer
|
||||||
|
? l10n.passwordsDiffer
|
||||||
|
: _phraseProblem(state.problem)
|
||||||
|
? null
|
||||||
|
: problem,
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: enabled ? _cubit.unlockShownAgain : null,
|
||||||
|
child: Text(l10n.passwordBack),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SimpleLoginStep.mend => Column(
|
SimpleLoginStep.mend => Column(
|
||||||
|
|
@ -351,6 +415,17 @@ class _SimpleLoginState() extends State<_SimpleLogin> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether [problem] is the recovery phrase's, shown at its field.
|
||||||
|
bool _phraseProblem(Exception? problem) => switch (problem) {
|
||||||
|
PasswordFailed(
|
||||||
|
problem: PasswordProblem.notAPhrase ||
|
||||||
|
PasswordProblem.phraseDoesNotFit ||
|
||||||
|
PasswordProblem.wrongCredentials,
|
||||||
|
) =>
|
||||||
|
true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
/// One of the ways on for someone new.
|
/// One of the ways on for someone new.
|
||||||
class const _Choice({
|
class const _Choice({
|
||||||
required final IconData icon,
|
required final IconData icon,
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,28 @@ class FakeAccountCrypto() implements AccountCrypto {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<protocol.KeysPassword> newKeysPassword(
|
||||||
|
String password,
|
||||||
|
UnlockedAccountKeys keys,
|
||||||
|
) async {
|
||||||
|
_checkNew(password);
|
||||||
|
return protocol.KeysPassword(
|
||||||
|
salt: ByteData(16),
|
||||||
|
costs: _costs,
|
||||||
|
passwordProofKey: passwordKeyOf(password),
|
||||||
|
passwordKeys: wrap(password, keys),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ByteData> signKeysResetProof(
|
||||||
|
String phrase,
|
||||||
|
protocol.KeyProofChallenge challenge, {
|
||||||
|
required String instanceUrl,
|
||||||
|
required protocol.KeysPassword password,
|
||||||
|
}) => signKeyProof(phrase, challenge, instanceUrl: instanceUrl);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ByteData> signMendProof(
|
Future<ByteData> signMendProof(
|
||||||
String phrase,
|
String phrase,
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,54 @@ class FakeSimpleLoginGateway(final AuthGateway _auth)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<protocol.KeyProofChallenge> beginPhrase({SessionGrant? as}) async {
|
||||||
|
await _call('beginPhrase');
|
||||||
|
final person = _owner(as);
|
||||||
|
final keys = accounts[person]?.keys;
|
||||||
|
if (keys == null) {
|
||||||
|
throw const PasswordFailed(PasswordProblem.noPassword);
|
||||||
|
}
|
||||||
|
final challenge = _newId();
|
||||||
|
_challenges[challenge] = person;
|
||||||
|
return protocol.KeyProofChallenge(
|
||||||
|
challenge: challenge,
|
||||||
|
nonce: ByteData(32),
|
||||||
|
encryptionPublicKey: keys.encryptionPublicKey,
|
||||||
|
signingPublicKey: keys.signingPublicKey,
|
||||||
|
recoveryKeys: keys.recoveryKeys,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> resetPassword(
|
||||||
|
protocol.KeysOwnerProof proof,
|
||||||
|
protocol.KeysPassword password, {
|
||||||
|
SessionGrant? as,
|
||||||
|
}) async {
|
||||||
|
await _call('resetPassword');
|
||||||
|
final person = _owner(as);
|
||||||
|
if (_signedIn[proof.attempt] != person) {
|
||||||
|
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
|
||||||
|
}
|
||||||
|
if (_challenges.remove(proof.challenge) != person) {
|
||||||
|
throw const PasswordFailed(PasswordProblem.challengeExpired);
|
||||||
|
}
|
||||||
|
final account = accounts[person]!;
|
||||||
|
final keys = account.keys!;
|
||||||
|
if (!FakeAccountCrypto.proves(proof.signature, keys.recoveryProofKey)) {
|
||||||
|
throw const PasswordFailed(PasswordProblem.wrongCredentials);
|
||||||
|
}
|
||||||
|
// The sign-in is taken by the change, as the register takes it.
|
||||||
|
_signedIn.remove(proof.attempt);
|
||||||
|
account.keys = keys.copyWith(
|
||||||
|
salt: password.salt,
|
||||||
|
costs: password.costs,
|
||||||
|
passwordProofKey: password.passwordProofKey,
|
||||||
|
passwordKeys: password.passwordKeys,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setKeys(
|
Future<void> setKeys(
|
||||||
protocol.UuidValue attempt,
|
protocol.UuidValue attempt,
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,108 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a lost password: the new one is a password the register takes, '
|
||||||
|
"wrapping the same keys and proving itself, and the phrase's proof "
|
||||||
|
"checks against the phrase's own key, bound to that password",
|
||||||
|
() async {
|
||||||
|
const newPassword = 'a new horse battery';
|
||||||
|
final next = await crypto.newKeysPassword(newPassword, keys.keys);
|
||||||
|
final taken = register.KeysPassword.fromJson(next.toJson());
|
||||||
|
final other = register.KeysPassword.fromJson(
|
||||||
|
(await crypto.newKeysPassword(
|
||||||
|
'another horse battery',
|
||||||
|
keys.keys,
|
||||||
|
)).toJson(),
|
||||||
|
);
|
||||||
|
final nonce = Uint8List.fromList(List.filled(32, 7));
|
||||||
|
final signature = await crypto.signKeysResetProof(
|
||||||
|
keys.phrase,
|
||||||
|
protocol.KeyProofChallenge(
|
||||||
|
challenge: challengeId,
|
||||||
|
nonce: ByteData.sublistView(nonce),
|
||||||
|
encryptionPublicKey: keys.setup.encryptionPublicKey,
|
||||||
|
signingPublicKey: keys.setup.signingPublicKey,
|
||||||
|
recoveryKeys: keys.setup.recoveryKeys,
|
||||||
|
),
|
||||||
|
instanceUrl: instanceUrl,
|
||||||
|
password: next,
|
||||||
|
);
|
||||||
|
Future<bool> checks(
|
||||||
|
ByteData publicKey,
|
||||||
|
register.KeysPassword bound, {
|
||||||
|
register.KeyProofPurpose purpose =
|
||||||
|
register.KeyProofPurpose.passwordReset,
|
||||||
|
}) => register.isAccountSignature(
|
||||||
|
register.keyProofMessage(
|
||||||
|
challengeId,
|
||||||
|
nonce,
|
||||||
|
instanceUrl: 'https://learn.halkyon.eu/',
|
||||||
|
purpose: purpose,
|
||||||
|
digest: register.newKeysPasswordDigest(bound),
|
||||||
|
),
|
||||||
|
signature: Uint8List.sublistView(signature),
|
||||||
|
publicKey: Uint8List.sublistView(publicKey),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(register.isKeysPassword(taken), isTrue);
|
||||||
|
expect(await checks(keysSetup.recoveryProofKey, taken), isTrue);
|
||||||
|
expect(await checks(keysSetup.passwordProofKey, taken), isFalse);
|
||||||
|
expect(await checks(keysSetup.recoveryProofKey, other), isFalse);
|
||||||
|
expect(
|
||||||
|
await checks(
|
||||||
|
keysSetup.recoveryProofKey,
|
||||||
|
taken,
|
||||||
|
purpose: register.KeyProofPurpose.newPassword,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
|
||||||
|
final proven = await crypto.proveKeysPassword(
|
||||||
|
newPassword,
|
||||||
|
protocol.KeysChallenge(
|
||||||
|
challenge: challengeId,
|
||||||
|
nonce: ByteData.sublistView(nonce),
|
||||||
|
salt: next.salt,
|
||||||
|
costs: next.costs,
|
||||||
|
),
|
||||||
|
instanceUrl: instanceUrl,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await register.isAccountSignature(
|
||||||
|
register.keyProofMessage(
|
||||||
|
challengeId,
|
||||||
|
nonce,
|
||||||
|
instanceUrl: 'https://learn.halkyon.eu/',
|
||||||
|
purpose: register.KeyProofPurpose.unlockKeys,
|
||||||
|
),
|
||||||
|
signature: Uint8List.sublistView(proven.proof.signature),
|
||||||
|
publicKey: Uint8List.sublistView(taken.passwordProofKey),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await crypto.openKeys(
|
||||||
|
proven.unlock,
|
||||||
|
next.passwordKeys,
|
||||||
|
encryptionPublicKey: keys.keys.encryptionPublicKey,
|
||||||
|
signingPublicKey: keys.keys.signingPublicKey,
|
||||||
|
),
|
||||||
|
keys.keys,
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
crypto.newKeysPassword('too short', keys.keys),
|
||||||
|
throwsA(
|
||||||
|
isA<PasswordFailed>().having(
|
||||||
|
(failed) => failed.problem,
|
||||||
|
'problem',
|
||||||
|
PasswordProblem.passwordTooShort,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"linking again: the phrase's proof checks against the phrase's own "
|
"linking again: the phrase's proof checks against the phrase's own "
|
||||||
'key, bound to the sign-in it links',
|
'key, bound to the sign-in it links',
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,51 @@ void main() {
|
||||||
expect(keys.kept, {'person-9': material.keys});
|
expect(keys.kept, {'person-9': material.keys});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('with the password lost, opens them with the recovery phrase under '
|
||||||
|
'a new password typed twice, and goes back to the password when '
|
||||||
|
'asked', () async {
|
||||||
|
final material = await crypto.newKeys(password);
|
||||||
|
gateway.addAccount(
|
||||||
|
'person-9',
|
||||||
|
subject: 'simplelogin-anna',
|
||||||
|
handle: 'anna',
|
||||||
|
keys: material.setup,
|
||||||
|
);
|
||||||
|
final cubit = cubitFor();
|
||||||
|
await cubit.started();
|
||||||
|
cubit.forgotChosen();
|
||||||
|
expect(cubit.state, at(SimpleLoginStep.forgot));
|
||||||
|
cubit.unlockShownAgain();
|
||||||
|
expect(cubit.state, at(SimpleLoginStep.unlock));
|
||||||
|
cubit.forgotChosen();
|
||||||
|
|
||||||
|
const newPassword = 'a new horse battery';
|
||||||
|
await cubit.forgotEntered(fakePhrase, newPassword, 'a new horse');
|
||||||
|
expect(cubit.state, at(SimpleLoginStep.forgot));
|
||||||
|
expect(cubit.state.passwordsDiffer, isTrue);
|
||||||
|
expect(gateway.calls, isNot(contains('beginPhrase')));
|
||||||
|
|
||||||
|
await cubit.forgotEntered(otherFakePhrase, newPassword, newPassword);
|
||||||
|
expect(
|
||||||
|
cubit.state,
|
||||||
|
at(
|
||||||
|
SimpleLoginStep.forgot,
|
||||||
|
problem: const PasswordFailed(PasswordProblem.phraseDoesNotFit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(cubit.state.passwordsDiffer, isFalse);
|
||||||
|
expect(auth.status, const SignedOut());
|
||||||
|
|
||||||
|
await cubit.forgotEntered(fakePhrase, newPassword, newPassword);
|
||||||
|
|
||||||
|
expect(cubit.state, at(SimpleLoginStep.done));
|
||||||
|
expect(
|
||||||
|
auth.status,
|
||||||
|
const SignedIn(authUserId: 'person-9', isIdentified: true),
|
||||||
|
);
|
||||||
|
expect(keys.kept, {'person-9': material.keys});
|
||||||
|
});
|
||||||
|
|
||||||
test('of before, without keys, makes them with the handle it '
|
test('of before, without keys, makes them with the handle it '
|
||||||
'lacks', () async {
|
'lacks', () async {
|
||||||
gateway.addAccount('person-9', subject: 'simplelogin-anna');
|
gateway.addAccount('person-9', subject: 'simplelogin-anna');
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,81 @@ void main() {
|
||||||
expect(sessionOf('halkyon_base.keys/unlock'), contains('anna-token'));
|
expect(sessionOf('halkyon_base.keys/unlock'), contains('anna-token'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sets a lost password on the account's own session, not yet the "
|
||||||
|
"device's: a sign-in taken or run out begins again, a phrase that "
|
||||||
|
"doesn't prove itself is the keys' refusal", () async {
|
||||||
|
final grant = SessionGrant(
|
||||||
|
'0191f0c4-2222-7000-8000-000000000002',
|
||||||
|
AuthSuccess(
|
||||||
|
authStrategy: 'session',
|
||||||
|
token: 'anna-token',
|
||||||
|
authUserId: UuidValue.withValidation(
|
||||||
|
'0191f0c4-2222-7000-8000-000000000002',
|
||||||
|
),
|
||||||
|
scopeNames: {'identified'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Future<void> reset() => gateway.resetPassword(
|
||||||
|
KeysOwnerProof(
|
||||||
|
challenge: _Instance.attempt,
|
||||||
|
signature: ByteData(64),
|
||||||
|
instanceUrl: gateway.instanceUrl,
|
||||||
|
attempt: _Instance.attempt,
|
||||||
|
),
|
||||||
|
KeysPassword(
|
||||||
|
salt: ByteData(16),
|
||||||
|
costs: PasswordCosts(
|
||||||
|
algorithm: 'argon2id13',
|
||||||
|
opsLimit: 3,
|
||||||
|
memLimit: 64 * 1024 * 1024,
|
||||||
|
),
|
||||||
|
passwordProofKey: ByteData(32),
|
||||||
|
passwordKeys: WrappedKeys(
|
||||||
|
nonce: ByteData(24),
|
||||||
|
cipherText: ByteData(48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
as: grant,
|
||||||
|
);
|
||||||
|
|
||||||
|
instance.refusals['halkyon_base.keys/resetPassword'] = OidcRefusedException(
|
||||||
|
reason: OidcRefusal.unknownAttempt,
|
||||||
|
);
|
||||||
|
await expectLater(reset(), failsWith(SimpleLoginProblem.startAgain));
|
||||||
|
expect(
|
||||||
|
sessionOf('halkyon_base.keys/resetPassword'),
|
||||||
|
contains('anna-token'),
|
||||||
|
);
|
||||||
|
|
||||||
|
instance.calls.clear();
|
||||||
|
instance.refusals['halkyon_base.keys/resetPassword'] =
|
||||||
|
PasswordRefusedException(reason: PasswordRefusal.wrongCredentials);
|
||||||
|
await expectLater(
|
||||||
|
reset(),
|
||||||
|
throwsA(
|
||||||
|
isA<PasswordFailed>().having(
|
||||||
|
(failed) => failed.problem,
|
||||||
|
'problem',
|
||||||
|
PasswordProblem.wrongCredentials,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
instance.refusals['halkyon_base.keys/beginPhrase'] =
|
||||||
|
PasswordRefusedException(reason: PasswordRefusal.noPassword);
|
||||||
|
await expectLater(
|
||||||
|
gateway.beginPhrase(as: grant),
|
||||||
|
throwsA(
|
||||||
|
isA<PasswordFailed>().having(
|
||||||
|
(failed) => failed.problem,
|
||||||
|
'problem',
|
||||||
|
PasswordProblem.noPassword,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(sessionOf('halkyon_base.keys/beginPhrase'), contains('anna-token'));
|
||||||
|
});
|
||||||
|
|
||||||
test("puts the instance's refusals of SimpleLogin as the app's problems", () {
|
test("puts the instance's refusals of SimpleLogin as the app's problems", () {
|
||||||
expect(
|
expect(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,93 @@ void main() {
|
||||||
expect(keys.kept, isEmpty);
|
expect(keys.kept, isEmpty);
|
||||||
expect(crypto.unlocks.single.isForgotten, isTrue);
|
expect(crypto.unlocks.single.isForgotten, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('with the password lost (ADR 0020, 7)', () {
|
||||||
|
const newPassword = 'a new horse battery';
|
||||||
|
|
||||||
|
test('the recovery phrase opens them and a new password wraps them: '
|
||||||
|
"once entered, the session and the same keys are the device's, "
|
||||||
|
'and the new password opens them, the old one no longer', () async {
|
||||||
|
final pending = await simpleLogin.resetPassword(
|
||||||
|
account,
|
||||||
|
phrase: fakePhrase,
|
||||||
|
newPassword: newPassword,
|
||||||
|
);
|
||||||
|
expect(auth.status, const SignedOut());
|
||||||
|
|
||||||
|
await simpleLogin.enter(pending);
|
||||||
|
|
||||||
|
expect(auth.usedSessions, [account.grant]);
|
||||||
|
expect(keys.kept, {'person-9': material.keys});
|
||||||
|
await expectLater(
|
||||||
|
simpleLogin.unlock(account, password),
|
||||||
|
keysFailWith(PasswordProblem.wrongCredentials),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await simpleLogin.unlock(account, newPassword)).keys,
|
||||||
|
material.keys,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a phrase that doesn't open the keys on the device: the "
|
||||||
|
'instance gets no proof to count, and the password stays', () async {
|
||||||
|
await expectLater(
|
||||||
|
simpleLogin.resetPassword(
|
||||||
|
account,
|
||||||
|
phrase: otherFakePhrase,
|
||||||
|
newPassword: newPassword,
|
||||||
|
),
|
||||||
|
keysFailWith(PasswordProblem.phraseDoesNotFit),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
simpleLogin.resetPassword(
|
||||||
|
account,
|
||||||
|
phrase: 'not a phrase',
|
||||||
|
newPassword: newPassword,
|
||||||
|
),
|
||||||
|
keysFailWith(PasswordProblem.notAPhrase),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(gateway.calls, isNot(contains('resetPassword')));
|
||||||
|
expect(keys.kept, isEmpty);
|
||||||
|
expect(
|
||||||
|
(await simpleLogin.unlock(account, password)).keys,
|
||||||
|
material.keys,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses a new password too short on the device, before anything '
|
||||||
|
'is sent', () async {
|
||||||
|
await expectLater(
|
||||||
|
simpleLogin.resetPassword(
|
||||||
|
account,
|
||||||
|
phrase: fakePhrase,
|
||||||
|
newPassword: 'too short',
|
||||||
|
),
|
||||||
|
keysFailWith(PasswordProblem.passwordTooShort),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(gateway.calls, isNot(contains('resetPassword')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('takes the sign-in it was made with: once it is used, the change '
|
||||||
|
'begins again with SimpleLogin', () async {
|
||||||
|
await simpleLogin.resetPassword(
|
||||||
|
account,
|
||||||
|
phrase: fakePhrase,
|
||||||
|
newPassword: newPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
simpleLogin.resetPassword(
|
||||||
|
account,
|
||||||
|
phrase: fakePhrase,
|
||||||
|
newPassword: 'yet another horse battery',
|
||||||
|
),
|
||||||
|
failsWith(SimpleLoginProblem.startAgain),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('an account of before, without keys', () {
|
group('an account of before, without keys', () {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue