feat(app): a reset of the keys without the phrase, its banner, and the keys' proof key for a password that signs in (ADR 0020, D31–D35)
SimpleLogin's sheet, its password and its recovery phrase both lost: under the lost-password step's phrase field, Lost your recovery phrase too? says what stays and what is lost and asks for new keys (`keys.startReset`) with the sign-in of a moment ago, which the device then drops. The next sign-in says when while it waits, and once due offers new keys, the sign-up's own password and phrase steps (`keys.completeReset`), or the password, which calls it off (D31). Every app's home shows `KeysResetBanner` where "too old" shows, and the account section's SimpleLogin row the same words (`KeysResetNotice`), each with Call it off, the password's proof alone (`keys.callOffResets`, D32), and, once due on a device that holds the keys, Finish here, which wraps the same keys anew (D33, D35). Both show only a reset of an account that signs in with SimpleLogin alone; ADR 0017's resets show in its own section, which now shares `AlertBox` with them. An account whose password signs in has no Forgot your password? there, and is asked its password and a code once while its keys lack the proof key, at a sign-in or on adding SimpleLogin (`password.setProofKey`, D34). `PasswordUnlock` carries the proof key, which its forgetting leaves, and the keys' password proves for a purpose given. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
fe2ef02c92
commit
1b79214764
7 changed files with 2711 additions and 248 deletions
|
|
@ -14,6 +14,7 @@ void main() {
|
|||
opsLimit: 3,
|
||||
memLimit: 64 * 1024 * 1024,
|
||||
),
|
||||
proofKey: Uint8List.fromList(List.filled(32, 5)),
|
||||
srpSecret: Uint8List.fromList(List.filled(32, 7)),
|
||||
keyEncryptionKey: Uint8List.fromList(List.filled(32, 9)),
|
||||
);
|
||||
|
|
@ -24,6 +25,11 @@ void main() {
|
|||
|
||||
expect(x, everyElement(0));
|
||||
expect(key, everyElement(0));
|
||||
expect(
|
||||
unlock.proofKey,
|
||||
everyElement(5),
|
||||
reason: 'the public half of the proof key is no secret',
|
||||
);
|
||||
});
|
||||
|
||||
test('a forgotten unlock is spent: its secrets are read no more, so no '
|
||||
|
|
@ -35,6 +41,7 @@ void main() {
|
|||
opsLimit: 3,
|
||||
memLimit: 64 * 1024 * 1024,
|
||||
),
|
||||
proofKey: Uint8List.fromList(List.filled(32, 5)),
|
||||
srpSecret: Uint8List.fromList(List.filled(32, 7)),
|
||||
keyEncryptionKey: Uint8List.fromList(List.filled(32, 9)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -185,6 +185,101 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a password that signs in gives its keys the proof key it makes (ADR '
|
||||
"0020, D34): the owner's SRP proof binds that key for the register, "
|
||||
"and the password's proof for unlocking then checks against it",
|
||||
() async {
|
||||
final verifier = register.numberOf(Uint8List.sublistView(setup.verifier));
|
||||
final challenge = register.srpChallenge(register.passwordGroup, verifier);
|
||||
final unlock = await crypto.unlock(
|
||||
password,
|
||||
salt: Uint8List.sublistView(made.setup.salt),
|
||||
costs: made.setup.costs,
|
||||
);
|
||||
final proofKey = ByteData.sublistView(
|
||||
Uint8List.fromList(unlock.proofKey),
|
||||
);
|
||||
final answer = await crypto.answer(
|
||||
unlock,
|
||||
protocol.PasswordChallenge(
|
||||
challenge: challengeId,
|
||||
salt: made.setup.salt,
|
||||
costs: made.setup.costs,
|
||||
serverValue: ByteData.sublistView(
|
||||
register.padded(
|
||||
challenge.publicValue,
|
||||
register.passwordGroup.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
instanceUrl: instanceUrl,
|
||||
purpose: device.PasswordProofPurpose.proofKey,
|
||||
digest: device.proofKeyDigest(unlock.proofKey),
|
||||
);
|
||||
unlock.forget();
|
||||
|
||||
register.SrpVerification verify(ByteData key) => register.srpVerify(
|
||||
register.passwordGroup,
|
||||
identity: register.passwordIdentity(
|
||||
challengeId,
|
||||
'https://learn.halkyon.eu/',
|
||||
purpose: register.PasswordProofPurpose.proofKey,
|
||||
digest: register.proofKeyDigest(key),
|
||||
),
|
||||
salt: Uint8List.sublistView(setup.salt),
|
||||
verifier: verifier,
|
||||
challenge: challenge,
|
||||
clientValue: register.numberOf(
|
||||
Uint8List.sublistView(answer.clientValue),
|
||||
),
|
||||
clientProof: Uint8List.sublistView(answer.clientProof),
|
||||
);
|
||||
|
||||
expect(proofKey.lengthInBytes, 32);
|
||||
expect(verify(proofKey), isA<register.SrpAccepted>());
|
||||
// Another key swapped in on the way: refused.
|
||||
expect(verify(ByteData(32)), isA<register.SrpRefused>());
|
||||
|
||||
final nonce = Uint8List.fromList(List.filled(32, 5));
|
||||
final proven = await crypto.proveKeysPassword(
|
||||
password,
|
||||
protocol.KeysChallenge(
|
||||
challenge: challengeId,
|
||||
nonce: ByteData.sublistView(nonce),
|
||||
salt: made.setup.salt,
|
||||
costs: made.setup.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(proofKey),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
await crypto.openKeys(
|
||||
proven.unlock,
|
||||
made.setup.passwordKeys,
|
||||
encryptionPublicKey: Uint8List.sublistView(
|
||||
made.setup.encryptionPublicKey,
|
||||
),
|
||||
signingPublicKey: Uint8List.sublistView(made.setup.signingPublicKey),
|
||||
),
|
||||
made.keys,
|
||||
);
|
||||
expect(proven.unlock.proofKey, unlock.proofKey);
|
||||
},
|
||||
);
|
||||
|
||||
group('an account that signs in with SimpleLogin (ADR 0020, D19)', () {
|
||||
late NewKeysMaterial keys;
|
||||
late register.KeysSetup keysSetup;
|
||||
|
|
@ -212,6 +307,40 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"calling off a reset of the keys: the password's proof made for it "
|
||||
'checks against its proof key for that alone (D32)',
|
||||
() async {
|
||||
final nonce = Uint8List.fromList(List.filled(32, 7));
|
||||
final proven = await crypto.proveKeysPassword(
|
||||
password,
|
||||
protocol.KeysChallenge(
|
||||
challenge: challengeId,
|
||||
nonce: ByteData.sublistView(nonce),
|
||||
salt: keys.setup.salt,
|
||||
costs: keys.setup.costs,
|
||||
),
|
||||
instanceUrl: instanceUrl,
|
||||
purpose: device.KeyProofPurpose.callOffResets,
|
||||
);
|
||||
proven.unlock.forget();
|
||||
Future<bool> checks(register.KeyProofPurpose purpose) =>
|
||||
register.isAccountSignature(
|
||||
register.keyProofMessage(
|
||||
challengeId,
|
||||
nonce,
|
||||
instanceUrl: 'https://learn.halkyon.eu/',
|
||||
purpose: purpose,
|
||||
),
|
||||
signature: Uint8List.sublistView(proven.proof.signature),
|
||||
publicKey: Uint8List.sublistView(keysSetup.passwordProofKey),
|
||||
);
|
||||
|
||||
expect(await checks(register.KeyProofPurpose.callOffResets), isTrue);
|
||||
expect(await checks(register.KeyProofPurpose.unlockKeys), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"unlocking: the password's proof checks against its own proof key, "
|
||||
'bound to the instance and to what it is for; the keys the register '
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
|
@ -326,6 +327,192 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('the password and the phrase lost: a reset of the keys (ADR 0020, '
|
||||
'D31, D33)', () {
|
||||
final asked = DateTime.utc(2026, 10, 24, 12);
|
||||
final due = asked.add(const Duration(hours: 72));
|
||||
late NewKeysMaterial material;
|
||||
|
||||
setUp(() async {
|
||||
material = await crypto.newKeys(password);
|
||||
gateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-anna',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
);
|
||||
});
|
||||
|
||||
test('is asked for from the lost password, saying when it takes '
|
||||
'effect; nobody is signed in meanwhile', () async {
|
||||
await withClock(Clock.fixed(asked), () async {
|
||||
final cubit = cubitFor();
|
||||
await cubit.started();
|
||||
cubit
|
||||
..forgotChosen()
|
||||
..phraseLost();
|
||||
expect(cubit.state, at(SimpleLoginStep.resetAsk));
|
||||
cubit.forgotChosen();
|
||||
expect(cubit.state, at(SimpleLoginStep.forgot));
|
||||
cubit.phraseLost();
|
||||
|
||||
await cubit.resetAsked();
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.resetAsked));
|
||||
expect(cubit.state.resetAt, due);
|
||||
expect(gateway.accounts['person-9']!.resetAt, due);
|
||||
expect(auth.status, const SignedOut());
|
||||
expect(keys.kept, isEmpty);
|
||||
cubit.finished();
|
||||
expect(cubit.state, at(SimpleLoginStep.closed));
|
||||
});
|
||||
});
|
||||
|
||||
test('waiting, the next sign-in says when, and the password opening the '
|
||||
'keys calls it off', () async {
|
||||
gateway.accounts['person-9']!.resetAt = due;
|
||||
await withClock(Clock.fixed(asked), () async {
|
||||
final cubit = cubitFor();
|
||||
await cubit.started();
|
||||
expect(cubit.state, at(SimpleLoginStep.unlock));
|
||||
expect(cubit.state.resetAt, due);
|
||||
|
||||
await cubit.unlockEntered(password);
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
expect(gateway.accounts['person-9']!.resetAt, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('due, gives new keys under a new password and phrase, or opens the '
|
||||
'old ones with the password', () async {
|
||||
gateway.accounts['person-9']!.resetAt = due;
|
||||
await withClock(Clock.fixed(due), () async {
|
||||
final cubit = cubitFor();
|
||||
await cubit.started();
|
||||
expect(cubit.state, at(SimpleLoginStep.resetDue));
|
||||
cubit.passwordChosen();
|
||||
expect(cubit.state, at(SimpleLoginStep.unlock));
|
||||
cubit.resetDueShownAgain();
|
||||
expect(cubit.state, at(SimpleLoginStep.resetDue));
|
||||
|
||||
cubit.newKeysChosen();
|
||||
expect(cubit.state, at(SimpleLoginStep.password));
|
||||
expect(cubit.state.resetting, isTrue);
|
||||
await makeKeys(cubit);
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
expect(
|
||||
auth.status,
|
||||
const SignedIn(authUserId: 'person-9', isIdentified: true),
|
||||
);
|
||||
final account = gateway.accounts['person-9']!;
|
||||
expect(account.keys, isNot(material.setup));
|
||||
expect(account.resetAt, isNull);
|
||||
expect(keys.kept['person-9'], isNot(material.keys));
|
||||
});
|
||||
});
|
||||
|
||||
test('due on a device that holds the keys, signed in, keeps them under '
|
||||
'the new password and phrase (D33)', () async {
|
||||
gateway.accounts['person-9']!.resetAt = due;
|
||||
await auth.useSession(const SessionGrant('person-9', 'session-9'));
|
||||
keys.kept['person-9'] = material.keys;
|
||||
await withClock(Clock.fixed(due), () async {
|
||||
final cubit = cubitFor(link: true);
|
||||
await cubit.started();
|
||||
expect(cubit.state, at(SimpleLoginStep.password));
|
||||
expect(cubit.state.resetting, isTrue);
|
||||
|
||||
await makeKeys(cubit);
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
expect(keys.kept, {'person-9': material.keys});
|
||||
expect(gateway.accounts['person-9']!.resetAt, isNull);
|
||||
expect(auth.usedSessions, hasLength(1), reason: 'no other session');
|
||||
});
|
||||
});
|
||||
|
||||
test('waiting on a device that holds the keys, adding SimpleLogin is done '
|
||||
'at once', () async {
|
||||
gateway.accounts['person-9']!.resetAt = due;
|
||||
await auth.useSession(const SessionGrant('person-9', 'session-9'));
|
||||
await withClock(Clock.fixed(asked), () async {
|
||||
final cubit = cubitFor(link: true);
|
||||
await cubit.started();
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
expect(gateway.accounts['person-9']!.resetAt, due);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('a password that signs in too (ADR 0020, D34)', () {
|
||||
late NewKeysMaterial material;
|
||||
|
||||
setUp(() async {
|
||||
material = await crypto.newKeys(password);
|
||||
gateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-anna',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
passwordSigningIn: password,
|
||||
proofKeyMissing: true,
|
||||
);
|
||||
});
|
||||
|
||||
test('opens the keys once it and a code set their proof key, a wrong '
|
||||
'code refused', () async {
|
||||
final cubit = cubitFor();
|
||||
await cubit.started();
|
||||
expect(cubit.state, at(SimpleLoginStep.proofKey));
|
||||
expect(cubit.state.signedInAs, 'anna');
|
||||
|
||||
await cubit.proofKeyEntered(password, '000000');
|
||||
expect(
|
||||
cubit.state,
|
||||
at(
|
||||
SimpleLoginStep.proofKey,
|
||||
problem: const PasswordFailed(PasswordProblem.wrongCode),
|
||||
),
|
||||
);
|
||||
expect(auth.status, const SignedOut());
|
||||
|
||||
await cubit.proofKeyEntered(password, fakeTotpCode);
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
expect(keys.kept, {'person-9': material.keys});
|
||||
expect(gateway.accounts['person-9']!.proofKeyMissing, isFalse);
|
||||
});
|
||||
|
||||
test('with the proof key, is asked alone, without the lost phrase, as its '
|
||||
"resets are ADR 0017's", () async {
|
||||
gateway.accounts['person-9']!.proofKeyMissing = false;
|
||||
final cubit = cubitFor();
|
||||
await cubit.started();
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.unlock));
|
||||
expect(cubit.state.passwordSignsIn, isTrue);
|
||||
});
|
||||
|
||||
test('adding SimpleLogin on a device signed in asks the same, and keeps '
|
||||
'its session', () async {
|
||||
await auth.useSession(const SessionGrant('person-9', 'session-9'));
|
||||
keys.kept['person-9'] = material.keys;
|
||||
final cubit = cubitFor(link: true);
|
||||
await cubit.started();
|
||||
expect(cubit.state, at(SimpleLoginStep.proofKey));
|
||||
|
||||
await cubit.proofKeyEntered(password, fakeTotpCode);
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
expect(gateway.accounts['person-9']!.proofKeyMissing, isFalse);
|
||||
expect(auth.usedSessions, hasLength(1), reason: 'no other session');
|
||||
expect(keys.kept, {'person-9': material.keys});
|
||||
});
|
||||
});
|
||||
|
||||
group("SimpleLogin added to the account of the device's person", () {
|
||||
test('a person without keys keeps those their device holds, and their '
|
||||
'session, once the keys are set', () async {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'dart:convert';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart' hide SignInWays;
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart';
|
||||
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart'
|
||||
show OidcRefusal, OidcRefusedException, OidcStart;
|
||||
|
|
@ -35,9 +35,26 @@ final class _Instance() {
|
|||
/// Each call, by its endpoint and method, with the session it carried.
|
||||
final calls = <({String call, String? session})>[];
|
||||
|
||||
/// What each call sent, by its endpoint and method.
|
||||
final bodies = <String, String>{};
|
||||
|
||||
/// What a sign-in ends with.
|
||||
SimpleLoginCompleted completed = SimpleLoginCompleted(
|
||||
outcome: SimpleLoginOutcome.newcomer,
|
||||
suggestedHandle: 'anna_mueller',
|
||||
hasKeys: false,
|
||||
);
|
||||
|
||||
/// The ways into the device's person's account.
|
||||
SignInWays ways = SignInWays(gitHub: false, email: false);
|
||||
|
||||
/// When a reset asked for takes effect.
|
||||
static final resetAt = DateTime.utc(2026, 10, 27, 12);
|
||||
|
||||
http.Client client() => MockClient((request) async {
|
||||
final call = request.url.path.substring(1);
|
||||
calls.add((call: call, session: request.headers['authorization']));
|
||||
bodies[call] = request.body;
|
||||
if (refusals[call] case final refusal?) {
|
||||
return switch (refusal) {
|
||||
final int status => http.Response('', status),
|
||||
|
|
@ -74,11 +91,31 @@ final class _Instance() {
|
|||
200,
|
||||
),
|
||||
'halkyon_base.simpleLogin/complete' => http.Response(
|
||||
sent(completed.toJson()),
|
||||
200,
|
||||
),
|
||||
'halkyon_base.account/signInWays' => http.Response(
|
||||
sent(ways.toJson()),
|
||||
200,
|
||||
),
|
||||
'halkyon_base.keys/startReset' => http.Response(
|
||||
sent(ResetPending(takesEffectAt: resetAt).toJson()),
|
||||
200,
|
||||
),
|
||||
'halkyon_base.keys/completeReset' ||
|
||||
'halkyon_base.keys/callOffResets' ||
|
||||
'halkyon_base.password/setProofKey' => http.Response('null', 200),
|
||||
'halkyon_base.password/beginChange' => http.Response(
|
||||
sent(
|
||||
SimpleLoginCompleted(
|
||||
outcome: SimpleLoginOutcome.newcomer,
|
||||
suggestedHandle: 'anna_mueller',
|
||||
hasKeys: false,
|
||||
PasswordChallenge(
|
||||
challenge: attempt,
|
||||
salt: ByteData(16),
|
||||
costs: PasswordCosts(
|
||||
algorithm: 'argon2id13',
|
||||
opsLimit: 3,
|
||||
memLimit: 64 * 1024 * 1024,
|
||||
),
|
||||
serverValue: ByteData(384),
|
||||
).toJson(),
|
||||
),
|
||||
200,
|
||||
|
|
@ -283,6 +320,204 @@ void main() {
|
|||
expect(sessionOf('halkyon_base.keys/beginPhrase'), contains('anna-token'));
|
||||
});
|
||||
|
||||
group('a reset of the keys and their proof key (ADR 0020, D31–D35)', () {
|
||||
final session = AuthSuccess(
|
||||
authStrategy: 'session',
|
||||
token: 'anna-token',
|
||||
authUserId: UuidValue.withValidation(
|
||||
'0191f0c4-2222-7000-8000-000000000002',
|
||||
),
|
||||
scopeNames: {'identified'},
|
||||
);
|
||||
final grant = SessionGrant(session.authUserId.uuid, session);
|
||||
|
||||
test("says when a reset of the device's person's keys takes effect, only "
|
||||
'for keys their password opens alone', () async {
|
||||
instance.ways = SignInWays(
|
||||
gitHub: false,
|
||||
email: false,
|
||||
simpleLogin: true,
|
||||
keys: true,
|
||||
accountResetAt: _Instance.resetAt,
|
||||
);
|
||||
expect(await gateway.standing(), (
|
||||
simpleLogin: true,
|
||||
keysResetAt: _Instance.resetAt,
|
||||
));
|
||||
expect(sessionOf('halkyon_base.account/signInWays'), contains('device'));
|
||||
|
||||
instance.ways = instance.ways.copyWith(password: true);
|
||||
expect(await gateway.standing(), (simpleLogin: true, keysResetAt: null));
|
||||
});
|
||||
|
||||
test('says on arrival whether the password signs in, whether the keys '
|
||||
'lack their proof key, and when a reset of them takes effect, but for '
|
||||
"a password that signs in, whose resets are ADR 0017's", () async {
|
||||
final start = await gateway.begin(SimpleLoginReturn.scheme);
|
||||
instance.completed = SimpleLoginCompleted(
|
||||
outcome: SimpleLoginOutcome.signedIn,
|
||||
session: session,
|
||||
hasKeys: true,
|
||||
handle: 'anna',
|
||||
hasProofKey: true,
|
||||
accountResetAt: _Instance.resetAt,
|
||||
);
|
||||
expect(
|
||||
await gateway.complete(start.attempt, 'code'),
|
||||
isA<SimpleLoginAccount>()
|
||||
.having((account) => account.passwordSignsIn, 'signs in', isFalse)
|
||||
.having((account) => account.proofKeyMissing, 'missing', isFalse)
|
||||
.having((account) => account.resetAt, 'reset', _Instance.resetAt),
|
||||
);
|
||||
|
||||
instance.completed = instance.completed.copyWith(
|
||||
passwordSignsIn: true,
|
||||
hasProofKey: false,
|
||||
);
|
||||
expect(
|
||||
await gateway.complete(start.attempt, 'code'),
|
||||
isA<SimpleLoginAccount>()
|
||||
.having((account) => account.passwordSignsIn, 'signs in', isTrue)
|
||||
.having((account) => account.proofKeyMissing, 'missing', isTrue)
|
||||
.having((account) => account.resetAt, 'reset', isNull),
|
||||
);
|
||||
});
|
||||
|
||||
test("asks for and carries out a reset on the account's own session, not "
|
||||
"yet the device's, and calls it off on the device's", () async {
|
||||
expect(
|
||||
await gateway.startReset(_Instance.attempt, as: grant),
|
||||
_Instance.resetAt,
|
||||
);
|
||||
await gateway.completeReset(
|
||||
_Instance.attempt,
|
||||
KeysSetup(
|
||||
salt: ByteData(16),
|
||||
costs: PasswordCosts(
|
||||
algorithm: 'argon2id13',
|
||||
opsLimit: 3,
|
||||
memLimit: 64 * 1024 * 1024,
|
||||
),
|
||||
passwordProofKey: ByteData(32),
|
||||
encryptionPublicKey: ByteData(32),
|
||||
signingPublicKey: ByteData(32),
|
||||
passwordKeys: WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(48),
|
||||
),
|
||||
recoveryKeys: WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(48),
|
||||
),
|
||||
recoveryProofKey: ByteData(32),
|
||||
),
|
||||
as: grant,
|
||||
);
|
||||
await gateway.callOffResets(
|
||||
KeysProof(
|
||||
challenge: _Instance.attempt,
|
||||
signature: ByteData(64),
|
||||
instanceUrl: gateway.instanceUrl,
|
||||
),
|
||||
);
|
||||
|
||||
expect(sessionOf('halkyon_base.keys/startReset'), contains('anna-token'));
|
||||
expect(
|
||||
sessionOf('halkyon_base.keys/completeReset'),
|
||||
contains('anna-token'),
|
||||
);
|
||||
expect(
|
||||
sessionOf('halkyon_base.keys/callOffResets'),
|
||||
contains('device-token'),
|
||||
);
|
||||
});
|
||||
|
||||
test('a reset before its time says when', () async {
|
||||
instance.refusals['halkyon_base.keys/completeReset'] =
|
||||
PasswordRefusedException(
|
||||
reason: PasswordRefusal.notDue,
|
||||
takesEffectAt: _Instance.resetAt,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
gateway.completeReset(
|
||||
_Instance.attempt,
|
||||
KeysSetup(
|
||||
salt: ByteData(16),
|
||||
costs: PasswordCosts(
|
||||
algorithm: 'argon2id13',
|
||||
opsLimit: 3,
|
||||
memLimit: 64 * 1024 * 1024,
|
||||
),
|
||||
passwordProofKey: ByteData(32),
|
||||
encryptionPublicKey: ByteData(32),
|
||||
signingPublicKey: ByteData(32),
|
||||
passwordKeys: WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(48),
|
||||
),
|
||||
recoveryKeys: WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(48),
|
||||
),
|
||||
recoveryProofKey: ByteData(32),
|
||||
),
|
||||
as: grant,
|
||||
),
|
||||
throwsA(
|
||||
isA<PasswordFailed>()
|
||||
.having(
|
||||
(failed) => failed.problem,
|
||||
'problem',
|
||||
PasswordProblem.notDue,
|
||||
)
|
||||
.having(
|
||||
(failed) => failed.takesEffectAt,
|
||||
'when',
|
||||
_Instance.resetAt,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("sets the keys' proof key with the owner's proof and its code, on "
|
||||
"the account's own session", () async {
|
||||
final challenge = await gateway.beginOwnerProof(as: grant);
|
||||
await gateway.setProofKey(
|
||||
(
|
||||
challenge: challenge,
|
||||
answer: SrpAnswer(
|
||||
clientValue: ByteData(384),
|
||||
clientProof: ByteData(32),
|
||||
verifies: (_) => true,
|
||||
),
|
||||
totpCode: '123456',
|
||||
recoveryCode: null,
|
||||
),
|
||||
ByteData(32),
|
||||
as: grant,
|
||||
);
|
||||
|
||||
expect(
|
||||
sessionOf('halkyon_base.password/beginChange'),
|
||||
contains('anna-token'),
|
||||
);
|
||||
expect(
|
||||
sessionOf('halkyon_base.password/setProofKey'),
|
||||
contains('anna-token'),
|
||||
);
|
||||
final sent = jsonDecode(
|
||||
instance.bodies['halkyon_base.password/setProofKey']!,
|
||||
) as Map<String, Object?>;
|
||||
expect(sent['proof'], containsPair('totpCode', '123456'));
|
||||
expect(
|
||||
sent['proof'],
|
||||
containsPair('instanceUrl', gateway.instanceUrl),
|
||||
);
|
||||
expect(sent, contains('proofKey'));
|
||||
});
|
||||
});
|
||||
|
||||
test("puts the instance's refusals of SimpleLogin as the app's problems", () {
|
||||
expect(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
|
@ -387,6 +388,251 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('a reset of the keys, the password and the phrase lost (ADR 0020, '
|
||||
'D31, D33)', () {
|
||||
const newPassword = 'a new horse battery';
|
||||
final asked = DateTime.utc(2026, 10, 24, 12);
|
||||
final due = asked.add(const Duration(hours: 72));
|
||||
late NewKeysMaterial material;
|
||||
|
||||
setUp(() async {
|
||||
material = await crypto.newKeys(password);
|
||||
gateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-anna',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
);
|
||||
});
|
||||
|
||||
Future<SimpleLoginAccount> signedIn({bool link = false}) async =>
|
||||
(await simpleLogin.signIn(link: link))! as SimpleLoginAccount;
|
||||
|
||||
test('is asked for with a SimpleLogin sign-in, which it takes, and '
|
||||
'takes effect 72 hours later; asked again, it keeps its time, which '
|
||||
'the next sign-in says', () async {
|
||||
await withClock(Clock.fixed(asked), () async {
|
||||
final account = await signedIn();
|
||||
expect(account.resetAt, isNull);
|
||||
|
||||
expect(await simpleLogin.startReset(account), due);
|
||||
await expectLater(
|
||||
simpleLogin.startReset(account),
|
||||
failsWith(SimpleLoginProblem.startAgain),
|
||||
);
|
||||
});
|
||||
await withClock(
|
||||
Clock.fixed(asked.add(const Duration(hours: 1))),
|
||||
() async {
|
||||
final again = await signedIn();
|
||||
expect(again.resetAt, due);
|
||||
expect(await simpleLogin.startReset(again), due);
|
||||
},
|
||||
);
|
||||
expect(auth.usedSessions, isEmpty, reason: 'nobody signed in with it');
|
||||
expect(keys.kept, isEmpty);
|
||||
});
|
||||
|
||||
test('once due, gives the account new keys under a new password and '
|
||||
'phrase: once entered, the session and the new keys are the '
|
||||
"device's, and the old password opens nothing", () async {
|
||||
await withClock(Clock.fixed(asked), () async {
|
||||
await simpleLogin.startReset(await signedIn());
|
||||
});
|
||||
await withClock(Clock.fixed(due), () async {
|
||||
final account = await signedIn();
|
||||
final made = await simpleLogin.makeKeys(newPassword, into: account);
|
||||
expect(made.keys, isNot(material.keys));
|
||||
|
||||
await simpleLogin.enter(
|
||||
await simpleLogin.completeReset(account, made: made),
|
||||
);
|
||||
|
||||
expect(auth.usedSessions, [account.grant]);
|
||||
expect(keys.kept, {'person-9': made.keys});
|
||||
expect(gateway.accounts['person-9']!.keys, made.setup);
|
||||
expect(gateway.accounts['person-9']!.resetAt, isNull);
|
||||
await expectLater(
|
||||
simpleLogin.unlock(account, password),
|
||||
keysFailWith(PasswordProblem.wrongCredentials),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('is refused before its time, saying when, and with nothing '
|
||||
'asked', () async {
|
||||
await withClock(Clock.fixed(asked), () async {
|
||||
final account = await signedIn();
|
||||
final made = await simpleLogin.makeKeys(newPassword, into: account);
|
||||
await expectLater(
|
||||
simpleLogin.completeReset(account, made: made),
|
||||
keysFailWith(PasswordProblem.noReset),
|
||||
);
|
||||
await simpleLogin.startReset(await signedIn());
|
||||
});
|
||||
await withClock(
|
||||
Clock.fixed(due.subtract(const Duration(minutes: 1))),
|
||||
() async {
|
||||
final account = await signedIn();
|
||||
final made = await simpleLogin.makeKeys(newPassword, into: account);
|
||||
await expectLater(
|
||||
simpleLogin.completeReset(account, made: made),
|
||||
throwsA(
|
||||
isA<PasswordFailed>()
|
||||
.having(
|
||||
(failed) => failed.problem,
|
||||
'problem',
|
||||
PasswordProblem.notDue,
|
||||
)
|
||||
.having((failed) => failed.takesEffectAt, 'when', due),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(gateway.accounts['person-9']!.keys, material.setup);
|
||||
expect(keys.kept, isEmpty);
|
||||
});
|
||||
|
||||
test('carried out on a device that holds the keys, signed in, keeps them '
|
||||
'under the new password and phrase (D33)', () async {
|
||||
await withClock(Clock.fixed(asked), () async {
|
||||
await simpleLogin.startReset(await signedIn());
|
||||
});
|
||||
await auth.useSession(const SessionGrant('person-9', 'session-9'));
|
||||
keys.kept['person-9'] = material.keys;
|
||||
await withClock(Clock.fixed(due), () async {
|
||||
final account = await signedIn(link: true);
|
||||
expect(account.grant, isNull);
|
||||
expect(account.resetAt, due);
|
||||
final made = await simpleLogin.makeKeys(newPassword, into: account);
|
||||
expect(made.keys, material.keys);
|
||||
|
||||
await simpleLogin.enter(
|
||||
await simpleLogin.completeReset(account, made: made),
|
||||
);
|
||||
|
||||
expect(auth.usedSessions, [
|
||||
const SessionGrant('person-9', 'session-9'),
|
||||
]);
|
||||
expect(keys.kept, {'person-9': material.keys});
|
||||
expect(gateway.accounts['person-9']!.keys, made.setup);
|
||||
});
|
||||
});
|
||||
|
||||
group('called off on a signed-in device (D32)', () {
|
||||
setUp(() async {
|
||||
await withClock(Clock.fixed(asked), () async {
|
||||
await simpleLogin.startReset(await signedIn());
|
||||
});
|
||||
await auth.useSession(const SessionGrant('person-9', 'session-9'));
|
||||
keys.kept['person-9'] = material.keys;
|
||||
});
|
||||
|
||||
test('with the password, read anew everywhere', () async {
|
||||
var changes = 0;
|
||||
final following = simpleLogin.changes.listen((_) => changes++);
|
||||
addTearDown(following.cancel);
|
||||
expect(
|
||||
await simpleLogin.standing(),
|
||||
SimpleLoginLinked(resetAt: due, keysHere: true),
|
||||
);
|
||||
|
||||
await simpleLogin.callOffReset(password);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(gateway.accounts['person-9']!.resetAt, isNull);
|
||||
expect(changes, greaterThan(0));
|
||||
expect(await simpleLogin.standing(), const SimpleLoginLinked());
|
||||
expect(crypto.unlocks.every((unlock) => unlock.isForgotten), isTrue);
|
||||
});
|
||||
|
||||
test('not with a wrong password', () async {
|
||||
await expectLater(
|
||||
simpleLogin.callOffReset('wrong horse battery'),
|
||||
keysFailWith(PasswordProblem.wrongCredentials),
|
||||
);
|
||||
|
||||
expect(gateway.accounts['person-9']!.resetAt, due);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group("the keys' proof key of a password that signs in (ADR 0020, "
|
||||
'D34)', () {
|
||||
late NewKeysMaterial material;
|
||||
late SimpleLoginAccount account;
|
||||
|
||||
setUp(() async {
|
||||
material = await crypto.newKeys(password);
|
||||
gateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-anna',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
passwordSigningIn: password,
|
||||
proofKeyMissing: true,
|
||||
);
|
||||
account = (await simpleLogin.signIn())! as SimpleLoginAccount;
|
||||
});
|
||||
|
||||
test('is missing, as the sign-in says, so the password opens '
|
||||
'nothing yet', () async {
|
||||
expect(account.passwordSignsIn, isTrue);
|
||||
expect(account.proofKeyMissing, isTrue);
|
||||
|
||||
await expectLater(
|
||||
simpleLogin.unlock(account, password),
|
||||
keysFailWith(PasswordProblem.noPassword),
|
||||
);
|
||||
});
|
||||
|
||||
test('is set by the password and a code, as the key that password makes, '
|
||||
'so the password then opens the keys', () async {
|
||||
await simpleLogin.addProofKey(
|
||||
account,
|
||||
password: password,
|
||||
code: '123 456',
|
||||
);
|
||||
|
||||
expect(gateway.accounts['person-9']!.proofKeyMissing, isFalse);
|
||||
expect(crypto.unlocks.single.isForgotten, isTrue);
|
||||
expect(
|
||||
(await simpleLogin.unlock(account, password)).keys,
|
||||
material.keys,
|
||||
);
|
||||
});
|
||||
|
||||
test('is not set by a wrong password, nor without the right '
|
||||
'code', () async {
|
||||
await expectLater(
|
||||
simpleLogin.addProofKey(
|
||||
account,
|
||||
password: 'wrong horse battery',
|
||||
code: fakeTotpCode,
|
||||
),
|
||||
keysFailWith(PasswordProblem.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
simpleLogin.addProofKey(account, password: password, code: '000000'),
|
||||
keysFailWith(PasswordProblem.wrongCode),
|
||||
);
|
||||
|
||||
expect(gateway.accounts['person-9']!.proofKeyMissing, isTrue);
|
||||
expect(crypto.unlocks.every((unlock) => unlock.isForgotten), isTrue);
|
||||
});
|
||||
|
||||
test('nor is a reset of the keys asked for: the password resets as '
|
||||
'ADR 0017 has it', () async {
|
||||
gateway.accounts['person-9']!.proofKeyMissing = false;
|
||||
final again = (await simpleLogin.signIn())! as SimpleLoginAccount;
|
||||
|
||||
await expectLater(
|
||||
simpleLogin.startReset(again),
|
||||
keysFailWith(PasswordProblem.noPassword),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('an account of before, without keys', () {
|
||||
late SimpleLoginAccount account;
|
||||
|
||||
|
|
@ -564,6 +810,34 @@ void main() {
|
|||
expect(await simpleLogin.standing(), const SimpleLoginLinked());
|
||||
});
|
||||
|
||||
test('a reset of its keys waiting says when, and whether the device '
|
||||
'holds the keys, which keep it (D35, D33)', () async {
|
||||
final at = DateTime.utc(2026, 10, 27, 12);
|
||||
gateway.addAccount('person-9', subject: 'simplelogin-ben', resetAt: at);
|
||||
await auth.useSession(const SessionGrant('person-9', 'session-9'));
|
||||
|
||||
expect(await simpleLogin.standing(), SimpleLoginLinked(resetAt: at));
|
||||
|
||||
keys.kept['person-9'] = crypto.keysNumbered(1);
|
||||
expect(
|
||||
await simpleLogin.standing(),
|
||||
SimpleLoginLinked(resetAt: at, keysHere: true),
|
||||
);
|
||||
});
|
||||
|
||||
test('an account whose password signs in shows none: its resets are '
|
||||
"ADR 0017's, in its own section", () async {
|
||||
gateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-ben',
|
||||
passwordSigningIn: password,
|
||||
resetAt: DateTime.utc(2026, 10, 27, 12),
|
||||
);
|
||||
await auth.useSession(const SessionGrant('person-9', 'session-9'));
|
||||
|
||||
expect(await simpleLogin.standing(), const SimpleLoginLinked());
|
||||
});
|
||||
|
||||
test('changes once SimpleLogin is added, and with the person', () async {
|
||||
await auth.switchToGitHub();
|
||||
var changes = 0;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
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';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const password = 'correct horse battery';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway gateway;
|
||||
late TestAppDependencies dependencies;
|
||||
late NewKeysMaterial material;
|
||||
|
||||
setUp(() async {
|
||||
gateway = FakeAuthGateway();
|
||||
dependencies = TestAppDependencies(gateway: gateway);
|
||||
dependencies.simpleLoginGateway.isOffered = true;
|
||||
material = await dependencies.crypto.newKeys(password);
|
||||
await gateway.useSession(const SessionGrant('person-9', 'session-9'));
|
||||
});
|
||||
tearDown(() => dependencies.dispose());
|
||||
|
||||
/// An account that signs in with SimpleLogin, its reset of the keys
|
||||
/// taking effect [at].
|
||||
void resetAt(DateTime at) => dependencies.simpleLoginGateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-anna',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
resetAt: at,
|
||||
);
|
||||
|
||||
/// An app's home, with the banner at its top, and its account section.
|
||||
Future<void> show(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(412, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
const Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
KeysResetBanner(),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SimpleLoginSection(gapAbove: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
dependencies: dependencies,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Finder field(String label) => find.widgetWithText(TextField, label);
|
||||
|
||||
Future<void> tapButton(WidgetTester tester, String label) async {
|
||||
final button = find.widgetWithText(FilledButton, label).last;
|
||||
await tester.ensureVisible(button);
|
||||
await tester.tap(button);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('nothing while no reset of the keys waits', (tester) async {
|
||||
dependencies.simpleLoginGateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-anna',
|
||||
);
|
||||
await show(tester);
|
||||
|
||||
expect(find.byType(MaterialBanner), findsNothing);
|
||||
expect(find.text('A reset is waiting'), findsNothing);
|
||||
expect(find.text('Signs you in'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a reset waiting shows at the top of the home and in the '
|
||||
'section, saying when; the password alone calls it off, a wrong one '
|
||||
'refused (ADR 0020, D35, D32)', (tester) async {
|
||||
resetAt(clock.now().add(const Duration(hours: 72)));
|
||||
dependencies.keys.kept['person-9'] = material.keys;
|
||||
await show(tester);
|
||||
|
||||
expect(find.byType(MaterialBanner), findsOneWidget);
|
||||
expect(find.text('A reset is waiting'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('whoever has it can set them up'),
|
||||
findsNWidgets(2),
|
||||
);
|
||||
expect(find.text('Finish here'), findsNothing);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Call it off').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Call off the reset'), findsOneWidget);
|
||||
await tester.enterText(field('Password'), 'wrong horse battery');
|
||||
await tapButton(tester, 'Call it off');
|
||||
expect(find.text("The password isn't right."), findsOneWidget);
|
||||
|
||||
await tester.enterText(field('Password'), password);
|
||||
await tapButton(tester, 'Call it off');
|
||||
|
||||
expect(find.text('Reset called off.'), findsOneWidget);
|
||||
expect(find.byType(MaterialBanner), findsNothing);
|
||||
expect(find.text('A reset is waiting'), findsNothing);
|
||||
expect(
|
||||
dependencies.simpleLoginGateway.accounts['person-9']!.resetAt,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('due on a device that holds the keys, it is finished here: a '
|
||||
'new password and phrase for the same keys (D33)', (tester) async {
|
||||
resetAt(clock.now().subtract(const Duration(hours: 1)));
|
||||
dependencies.keys.kept['person-9'] = material.keys;
|
||||
await show(tester);
|
||||
|
||||
expect(find.textContaining('finish here'), findsNWidgets(2));
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish here').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('A new password'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('Choose a new password: your keys stay'),
|
||||
findsOneWidget,
|
||||
);
|
||||
const newPassword = 'a new horse battery';
|
||||
await tester.enterText(field('New password'), newPassword);
|
||||
await tester.enterText(field('New password again'), newPassword);
|
||||
await tapButton(tester, 'Continue');
|
||||
await tapButton(tester, "I've written it down");
|
||||
final words = wordsOf(fakePhrase);
|
||||
for (final element in find.byType(TextField).evaluate()) {
|
||||
if ((element.widget as TextField).decoration?.labelText case final label?
|
||||
when label.startsWith('Word ')) {
|
||||
await tester.enterText(
|
||||
field(label),
|
||||
words[int.parse(label.substring('Word '.length)) - 1],
|
||||
);
|
||||
}
|
||||
}
|
||||
await tapButton(tester, 'Set up the keys');
|
||||
|
||||
expect(find.text('New password and recovery phrase set.'), findsOneWidget);
|
||||
expect(find.byType(MaterialBanner), findsNothing);
|
||||
expect(dependencies.keys.kept, {'person-9': material.keys});
|
||||
final account = dependencies.simpleLoginGateway.accounts['person-9']!;
|
||||
expect(account.keys, isNot(material.setup));
|
||||
// The same keys, wrapped anew.
|
||||
expect(
|
||||
Uint8List.sublistView(account.keys!.encryptionPublicKey),
|
||||
Uint8List.sublistView(material.setup.encryptionPublicKey),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('due on a device without the keys, it says whoever has the '
|
||||
'SimpleLogin account can set them up now, and offers only calling it '
|
||||
'off', (tester) async {
|
||||
resetAt(clock.now().subtract(const Duration(hours: 1)));
|
||||
await show(tester);
|
||||
|
||||
expect(
|
||||
find.textContaining('whoever has it can set them up now'),
|
||||
findsNWidgets(2),
|
||||
);
|
||||
expect(find.text('Finish here'), findsNothing);
|
||||
expect(find.widgetWithText(TextButton, 'Call it off'), findsNWidgets(2));
|
||||
});
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -213,6 +214,132 @@ void main() {
|
|||
expect(dependencies.keys.kept, {'person-9': material.keys});
|
||||
});
|
||||
|
||||
testWidgets('with the recovery phrase lost too, new keys are asked for, '
|
||||
'72 hours later; the next sign-in says when, and the password calls it '
|
||||
'off (ADR 0020, D31)', (tester) async {
|
||||
final material = await dependencies.crypto.newKeys(password);
|
||||
dependencies.simpleLoginGateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-anna',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
);
|
||||
await start(tester);
|
||||
|
||||
await tapButton(tester, 'Continue with SimpleLogin');
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Forgot your password?'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.widgetWithText(TextButton, 'Lost your recovery phrase too?'),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('New keys'), findsOneWidget);
|
||||
expect(find.textContaining('72 hours from now'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Back'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('A new password'), findsOneWidget);
|
||||
await tester.tap(
|
||||
find.widgetWithText(TextButton, 'Lost your recovery phrase too?'),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tapButton(tester, 'Ask for new keys');
|
||||
expect(find.text('New keys asked for'), findsOneWidget);
|
||||
final at = dependencies.simpleLoginGateway.accounts['person-9']!.resetAt;
|
||||
expect(at, isNotNull);
|
||||
await tapButton(tester, 'Close');
|
||||
expect(find.text('Continue with SimpleLogin'), findsOneWidget);
|
||||
expect(gateway.status, const SignedOut());
|
||||
|
||||
await tapButton(tester, 'Continue with SimpleLogin');
|
||||
expect(find.text('A reset is waiting'), findsOneWidget);
|
||||
await tester.enterText(field('Password'), password);
|
||||
await tapButton(tester, 'Open');
|
||||
|
||||
expect(find.text('App of person-9'), findsOneWidget);
|
||||
expect(
|
||||
dependencies.simpleLoginGateway.accounts['person-9']!.resetAt,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a reset due gives new keys with a new password and phrase, or '
|
||||
'the old ones open with the password', (tester) async {
|
||||
final material = await dependencies.crypto.newKeys(password);
|
||||
dependencies.simpleLoginGateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-anna',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
resetAt: clock.now().subtract(const Duration(hours: 1)),
|
||||
);
|
||||
await start(tester);
|
||||
|
||||
await tapButton(tester, 'Continue with SimpleLogin');
|
||||
expect(find.text('Your new keys'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(ListTile, 'I have my password'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Your password'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Back'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(ListTile, 'Set up new keys'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('New keys'), findsOneWidget);
|
||||
const newPassword = 'a new horse battery';
|
||||
await tester.enterText(field('New password'), newPassword);
|
||||
await tester.enterText(field('New password again'), newPassword);
|
||||
await tapButton(tester, 'Continue');
|
||||
await tapButton(tester, "I've written it down");
|
||||
await typeCheckedWords(tester, fakePhrase);
|
||||
await tapButton(tester, 'Set up the keys');
|
||||
|
||||
expect(find.text('App of person-9'), findsOneWidget);
|
||||
expect(dependencies.keys.kept['person-9'], isNot(material.keys));
|
||||
});
|
||||
|
||||
testWidgets('an account whose password signs in too gives its keys their '
|
||||
'proof key with the password and a code, once, and offers no lost '
|
||||
'password here (ADR 0020, D34)', (tester) async {
|
||||
final material = await dependencies.crypto.newKeys(password);
|
||||
dependencies.simpleLoginGateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-anna',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
passwordSigningIn: password,
|
||||
proofKeyMissing: true,
|
||||
);
|
||||
await start(tester);
|
||||
|
||||
await tapButton(tester, 'Continue with SimpleLogin');
|
||||
expect(find.text('Your password and a code'), findsOneWidget);
|
||||
expect(find.textContaining('as @anna'), findsOneWidget);
|
||||
await tester.enterText(field('Password'), password);
|
||||
await tester.enterText(field('Code from your authenticator app'), '000000');
|
||||
await tapButton(tester, 'Open');
|
||||
expect(
|
||||
find.text("This code isn't right. Enter the one your app shows now."),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.enterText(field('Password'), password);
|
||||
await tester.enterText(
|
||||
field('Code from your authenticator app'),
|
||||
fakeTotpCode,
|
||||
);
|
||||
await tapButton(tester, 'Open');
|
||||
|
||||
expect(find.text('App of person-9'), findsOneWidget);
|
||||
expect(dependencies.keys.kept, {'person-9': material.keys});
|
||||
await gateway.signOut();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tapButton(tester, 'Continue with SimpleLogin');
|
||||
expect(find.text('Your password'), findsOneWidget);
|
||||
expect(find.text('Forgot your password?'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('someone whose SimpleLogin account was lost links theirs '
|
||||
'again with its handle and recovery phrase', (tester) async {
|
||||
final material = await dependencies.crypto.newKeys(password);
|
||||
|
|
|
|||
Loading…
Reference in a new issue