simpleLogin.begin takes the way back (SimpleLoginReturn: loopback, scheme) rather than the platform, so macOS can come back through its scheme as iOS and Android do; the markers become learn-loopback, learn-scheme, todo-loopback and todo-scheme. Not breaking: the endpoint has never been released. D9 corrected by the developer after research: Android, iOS and macOS come back through the app's scheme, Linux and Windows through the loopback. Verified links protected nothing here: a code caught on its way is worthless without the attempt's ID, and a hostile app beginning an attempt of its own can ask for a way back that other platforms need open. The ADR notes the residual risk and the check of prompt=consent still to do. assetlinks.json stays served. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
954 lines
33 KiB
Dart
954 lines
33 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:clock/clock.dart';
|
|
import 'package:cryptography/cryptography.dart';
|
|
import 'package:halkyon_base_server/base.dart';
|
|
import 'package:halkyon_base_server/src/password/passwords.dart'
|
|
show passwordRowOf;
|
|
import 'package:halkyon_oidc_server/oidc.dart';
|
|
import 'package:halkyon_oidc_server/testing.dart';
|
|
import 'package:serverpod/serverpod.dart';
|
|
import 'package:serverpod_auth_idp_server/core.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
import '../support/fixtures.dart';
|
|
import '../support/keys_device.dart';
|
|
import '../support/mailbox.dart';
|
|
import '../support/password_device.dart';
|
|
import '../support/password_support.dart';
|
|
import '../test_tools/serverpod_test_tools.dart';
|
|
|
|
// The keys of an account that signs in with SimpleLogin on a signed-in
|
|
// device (Halkyon Learn's ADR 0020, 7, D19 and D20): the password proven
|
|
// with its own proof key, a signature over the register's nonce, before the
|
|
// device gets the keys wrapped under it; the changes, each with that proof
|
|
// (the phrase's for a lost password) and a SimpleLogin sign-in of the last
|
|
// ten minutes, against a fake SimpleLogin (halkyon_oidc's testing.dart).
|
|
// Every way a proof is refused, and a session's wrong proofs ending it as
|
|
// ADR 0017's do.
|
|
|
|
void main() {
|
|
late TestKey key;
|
|
|
|
setUpAll(() => key = TestKey.generate('sl', seed: 13));
|
|
|
|
withServerpod(
|
|
'The keys of an account that signs in with SimpleLogin',
|
|
// Sessions ended for wrong proofs, in transactions of their own: these
|
|
// tests delete what they make instead.
|
|
rollbackDatabase: RollbackDatabase.disabled,
|
|
(sessionBuilder, endpoints) {
|
|
late Session session;
|
|
late KeysDevice device;
|
|
late FakeProvider simpleLogin;
|
|
|
|
/// With keys, signed in on her phone and her laptop; SimpleLogin's
|
|
/// `sl-anna`.
|
|
late UuidValue anna;
|
|
late UuidValue annasPhone;
|
|
late UuidValue annasLaptop;
|
|
|
|
/// Without keys, signed in on his laptop; SimpleLogin's `sl-ben`.
|
|
late UuidValue ben;
|
|
late UuidValue bensLaptop;
|
|
|
|
setUp(() async {
|
|
useBase(sessionBuilder, app: 'learn');
|
|
session = sessionBuilder.build();
|
|
await _deleteAll(session);
|
|
simpleLogin = FakeProvider.simpleLogin(
|
|
key: key,
|
|
clientId: 'halkyon-learn',
|
|
clientSecret: 'a secret of the app',
|
|
callbackUrl: Uri.parse('https://learn.test/simplelogin-callback'),
|
|
);
|
|
useSimpleLogin(sessionBuilder, simpleLogin);
|
|
device = await KeysDevice.make();
|
|
anna = (await AuthServices.instance.authUsers.create(
|
|
session,
|
|
scopes: {identified},
|
|
)).id;
|
|
await giveKeys(session, anna, device);
|
|
annasPhone = await signInNewDevice(
|
|
session,
|
|
anna,
|
|
method: 'simplelogin',
|
|
);
|
|
annasLaptop = await signInNewDevice(
|
|
session,
|
|
anna,
|
|
method: 'simplelogin',
|
|
);
|
|
ben = (await AuthServices.instance.authUsers.create(
|
|
session,
|
|
scopes: {identified},
|
|
)).id;
|
|
bensLaptop = await signInNewDevice(session, ben, method: 'simplelogin');
|
|
for (final (person, subject) in [(anna, 'sl-anna'), (ben, 'sl-ben')]) {
|
|
await OidcIdentity.db.insertRow(
|
|
session,
|
|
OidcIdentity(
|
|
authUserId: person,
|
|
provider: simpleLoginProvider,
|
|
subject: subject,
|
|
),
|
|
);
|
|
}
|
|
});
|
|
tearDown(() async {
|
|
useSimpleLoginApp(session.serverpod, null);
|
|
await _deleteAll(sessionBuilder.build());
|
|
});
|
|
|
|
TestSessionBuilder signedIn(UuidValue person, UuidValue on) =>
|
|
sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.authenticationInfo(
|
|
person.uuid,
|
|
{identified},
|
|
authId: on.uuid,
|
|
),
|
|
);
|
|
TestSessionBuilder onPhone() => signedIn(anna, annasPhone);
|
|
|
|
OidcIdp idp() => AuthServices.instance.oidcIdp;
|
|
|
|
Matcher refusedAsOidc(OidcRefusal reason) => throwsA(
|
|
isA<OidcRefusedException>().having((e) => e.reason, 'reason', reason),
|
|
);
|
|
Matcher refusedAsProfile(ProfileRefusal reason) => throwsA(
|
|
isA<ProfileRefusedException>().having(
|
|
(e) => e.reason,
|
|
'reason',
|
|
reason,
|
|
),
|
|
);
|
|
|
|
/// A SimpleLogin sign-in on [as], as [subject], just now: its attempt,
|
|
/// which stands for it ten minutes (D20).
|
|
Future<UuidValue> signInAgain(
|
|
TestSessionBuilder as,
|
|
String subject,
|
|
) async {
|
|
final start = await endpoints.simpleLogin.begin(
|
|
as,
|
|
back: SimpleLoginReturn.loopback,
|
|
);
|
|
await endpoints.simpleLogin.complete(
|
|
as,
|
|
attempt: start.attempt,
|
|
code: simpleLogin.authorize(start.authorizationUrl, subject: subject),
|
|
);
|
|
return start.attempt;
|
|
}
|
|
|
|
Future<Set<UuidValue>> sessionsOf(UuidValue person) async => {
|
|
for (final row in await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(person),
|
|
))
|
|
row.id!,
|
|
};
|
|
|
|
group('unlocking them', () {
|
|
test('gives the salt and costs, then the keys once the password '
|
|
'proved right', () async {
|
|
final challenge = await endpoints.keys.begin(onPhone());
|
|
|
|
expect(Uint8List.sublistView(challenge.salt), device.salt);
|
|
expect(challenge.costs.toJson(), newPasswordCosts.toJson());
|
|
expect(challenge.nonce.lengthInBytes, 32);
|
|
|
|
final keys = await endpoints.keys.unlock(
|
|
onPhone(),
|
|
await device.prove(challenge),
|
|
);
|
|
|
|
expect(
|
|
Uint8List.sublistView(keys.signingPublicKey),
|
|
device.signingPublicKey,
|
|
);
|
|
expect(
|
|
Uint8List.sublistView(keys.encryptionPublicKey),
|
|
device.encryptionPublicKey,
|
|
);
|
|
expect(keys.passwordKeys.toJson(), device.passwordKeys.toJson());
|
|
});
|
|
|
|
test('refuses a signature by another key: the recovery phrase, or '
|
|
"another password's", () async {
|
|
for (final keys in [
|
|
device.recoveryProofKeys,
|
|
(await KeysDevice.make(seed: 5)).passwordProofKeys,
|
|
]) {
|
|
final challenge = await endpoints.keys.begin(onPhone());
|
|
await expectLater(
|
|
endpoints.keys.unlock(
|
|
onPhone(),
|
|
await device.prove(challenge, keys: keys),
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('refuses a proof made for another purpose or change', () async {
|
|
for (final (purpose, digest) in [
|
|
(KeyProofPurpose.deleteAccount, const <int>[]),
|
|
(KeyProofPurpose.unlockKeys, const [1]),
|
|
]) {
|
|
final challenge = await endpoints.keys.begin(onPhone());
|
|
await expectLater(
|
|
endpoints.keys.unlock(
|
|
onPhone(),
|
|
await device.prove(challenge, purpose: purpose, digest: digest),
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('refuses a proof made for another instance, which a hostile '
|
|
'one could relay here', () async {
|
|
final challenge = await endpoints.keys.begin(onPhone());
|
|
|
|
await expectLater(
|
|
endpoints.keys.unlock(
|
|
onPhone(),
|
|
await device.prove(
|
|
challenge,
|
|
instanceUrl: 'https://evil.example/',
|
|
claimedUrl: testInstanceUrl,
|
|
),
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
await expectLater(
|
|
endpoints.keys.unlock(
|
|
onPhone(),
|
|
await device.prove(
|
|
await endpoints.keys.begin(onPhone()),
|
|
instanceUrl: 'https://evil.example/',
|
|
),
|
|
),
|
|
refused(PasswordRefusal.otherInstance),
|
|
);
|
|
});
|
|
|
|
test('takes each challenge once, and only from whom it was begun '
|
|
'for', () async {
|
|
final challenge = await endpoints.keys.begin(onPhone());
|
|
await endpoints.keys.unlock(
|
|
onPhone(),
|
|
await device.prove(challenge),
|
|
);
|
|
|
|
await expectLater(
|
|
endpoints.keys.unlock(onPhone(), await device.prove(challenge)),
|
|
refused(PasswordRefusal.challengeExpired),
|
|
);
|
|
final annas = await endpoints.keys.begin(onPhone());
|
|
await giveKeys(session, ben, await KeysDevice.make(seed: 3));
|
|
await expectLater(
|
|
endpoints.keys.unlock(
|
|
signedIn(ben, bensLaptop),
|
|
await device.prove(annas),
|
|
),
|
|
refused(PasswordRefusal.challengeExpired),
|
|
);
|
|
});
|
|
|
|
test('refuses an account without such keys: none, or a password '
|
|
'that signs in (ADR 0017)', () async {
|
|
await expectLater(
|
|
endpoints.keys.begin(signedIn(ben, bensLaptop)),
|
|
refused(PasswordRefusal.noPassword),
|
|
);
|
|
await AccountPassword.db.insertRow(
|
|
session,
|
|
passwordRowOf(
|
|
ben,
|
|
(await PasswordDevice.make()).setup(),
|
|
now: DateTime.now().toUtc(),
|
|
),
|
|
);
|
|
await expectLater(
|
|
endpoints.keys.begin(signedIn(ben, bensLaptop)),
|
|
refused(PasswordRefusal.noPassword),
|
|
);
|
|
});
|
|
|
|
test('takes no session at all', () async {
|
|
final notSignedIn = sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.unauthenticated(),
|
|
);
|
|
await expectLater(
|
|
endpoints.keys.begin(notSignedIn),
|
|
throwsA(isA<ServerpodUnauthenticatedException>()),
|
|
);
|
|
});
|
|
|
|
test("a session's wrong proofs count for it alone: the tenth in a "
|
|
"row ends it, the owner's other session unlocking as before, and "
|
|
'the owner is warned', () async {
|
|
final other = await KeysDevice.make(seed: 5);
|
|
for (var i = 0; i < 9; i++) {
|
|
await expectLater(
|
|
endpoints.keys.unlock(
|
|
onPhone(),
|
|
await other.prove(await endpoints.keys.begin(onPhone())),
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
}
|
|
await expectLater(
|
|
endpoints.keys.unlock(
|
|
onPhone(),
|
|
await other.prove(await endpoints.keys.begin(onPhone())),
|
|
),
|
|
refused(PasswordRefusal.sessionEnded),
|
|
);
|
|
|
|
expect(await sessionsOf(anna), {annasLaptop});
|
|
expect(
|
|
(await endpoints.account.signInWays(
|
|
signedIn(anna, annasLaptop),
|
|
)).sessionEndedForProofsAt,
|
|
isNotNull,
|
|
);
|
|
final laptop = signedIn(anna, annasLaptop);
|
|
await endpoints.keys.unlock(
|
|
laptop,
|
|
await device.prove(await endpoints.keys.begin(laptop)),
|
|
);
|
|
});
|
|
|
|
test('a right proof clears the wrong ones before it', () async {
|
|
final other = await KeysDevice.make(seed: 5);
|
|
for (var round = 0; round < 2; round++) {
|
|
for (var i = 0; i < 9; i++) {
|
|
await expectLater(
|
|
endpoints.keys.unlock(
|
|
onPhone(),
|
|
await other.prove(await endpoints.keys.begin(onPhone())),
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
}
|
|
await endpoints.keys.unlock(
|
|
onPhone(),
|
|
await device.prove(await endpoints.keys.begin(onPhone())),
|
|
);
|
|
}
|
|
expect(await sessionsOf(anna), {annasPhone, annasLaptop});
|
|
});
|
|
});
|
|
|
|
group('first keys, for an account SimpleLogin was added to (D20)', () {
|
|
late KeysDevice bens;
|
|
|
|
setUp(() async => bens = await KeysDevice.make(seed: 3));
|
|
|
|
TestSessionBuilder onBensLaptop() => signedIn(ben, bensLaptop);
|
|
|
|
Future<void> setWith(
|
|
UuidValue attempt, {
|
|
KeysSetup? setup,
|
|
String? handle = 'ben',
|
|
}) => endpoints.keys.set(
|
|
onBensLaptop(),
|
|
attempt: attempt,
|
|
setup: setup ?? bens.setup(),
|
|
handle: handle,
|
|
);
|
|
|
|
test('are set with a SimpleLogin sign-in of the last ten minutes, '
|
|
'taken, with a handle for an account without one, and then '
|
|
'unlock', () async {
|
|
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
|
|
|
|
await setWith(attempt, handle: 'Ben');
|
|
|
|
final row = (await AccountPassword.db.findFirstRow(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
))!;
|
|
expect(row.verifier, isNull);
|
|
expect(
|
|
Uint8List.sublistView(row.passwordProofKey!),
|
|
bens.passwordProofKey,
|
|
);
|
|
expect(
|
|
(await PublicProfile.db.findFirstRow(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
))!.handle,
|
|
'Ben',
|
|
);
|
|
expect(
|
|
(await endpoints.account.signInWays(onBensLaptop())).keys,
|
|
isTrue,
|
|
);
|
|
expect(
|
|
await idp().isKept(session, attempt: attempt, caller: ben),
|
|
isFalse,
|
|
);
|
|
await endpoints.keys.unlock(
|
|
onBensLaptop(),
|
|
await bens.prove(await endpoints.keys.begin(onBensLaptop())),
|
|
);
|
|
});
|
|
|
|
test('keep the handle an account has, refusing another', () async {
|
|
await AccountRegister.of(session.serverpod).saveProfile(
|
|
session,
|
|
ben,
|
|
handle: 'Ben',
|
|
displayName: '',
|
|
bio: '',
|
|
links: const [],
|
|
);
|
|
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
|
|
|
|
await expectLater(
|
|
setWith(attempt, handle: 'Benjamin'),
|
|
refusedAsProfile(ProfileRefusal.handleInvalid),
|
|
);
|
|
await setWith(attempt, handle: null);
|
|
expect(
|
|
(await PublicProfile.db.findFirstRow(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
))!.handle,
|
|
'Ben',
|
|
);
|
|
});
|
|
|
|
test(
|
|
'refuse a handle missing or refused as saving a profile refuses '
|
|
'it, and a setup out of range, the sign-in still standing',
|
|
() async {
|
|
await AccountRegister.of(session.serverpod).saveProfile(
|
|
session,
|
|
anna,
|
|
handle: 'Anna',
|
|
displayName: '',
|
|
bio: '',
|
|
links: const [],
|
|
);
|
|
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
|
|
|
|
for (final (handle, reason) in [
|
|
(null, ProfileRefusal.handleInvalid),
|
|
('ANNA', ProfileRefusal.handleTaken),
|
|
('admin', ProfileRefusal.handleReserved),
|
|
('ben!', ProfileRefusal.handleInvalid),
|
|
]) {
|
|
await expectLater(
|
|
setWith(attempt, handle: handle),
|
|
refusedAsProfile(reason),
|
|
reason: '$handle',
|
|
);
|
|
}
|
|
await expectLater(
|
|
setWith(
|
|
attempt,
|
|
setup: bens.setup().copyWith(salt: ByteData(15)),
|
|
),
|
|
refused(PasswordRefusal.invalidSetup),
|
|
);
|
|
await setWith(attempt);
|
|
},
|
|
);
|
|
|
|
test("count a new handle among the person's answers about handles "
|
|
'(30 an hour), the sign-in still standing when refused', () async {
|
|
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
|
|
for (var i = 0; i < handleChecksPerAsker; i++) {
|
|
await countHandleCheck(session, asker: ben.uuid, network: null);
|
|
}
|
|
|
|
await expectLater(
|
|
setWith(attempt),
|
|
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
|
|
);
|
|
expect(
|
|
await idp().isKept(session, attempt: attempt, caller: ben),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('refuse an account with keys already, under either kind of '
|
|
'password', () async {
|
|
final annas = await signInAgain(onPhone(), 'sl-anna');
|
|
await expectLater(
|
|
endpoints.keys.set(onPhone(), attempt: annas, setup: bens.setup()),
|
|
refused(PasswordRefusal.passwordExists),
|
|
);
|
|
await AccountPassword.db.insertRow(
|
|
session,
|
|
passwordRowOf(
|
|
ben,
|
|
(await PasswordDevice.make()).setup(),
|
|
now: DateTime.now().toUtc(),
|
|
),
|
|
);
|
|
await expectLater(
|
|
setWith(await signInAgain(onBensLaptop(), 'sl-ben')),
|
|
refused(PasswordRefusal.passwordExists),
|
|
);
|
|
});
|
|
|
|
test("refuse without a SimpleLogin sign-in of the account's: none, "
|
|
"past its ten minutes, or another account's, which stands for "
|
|
'it still', () async {
|
|
final annas = await signInAgain(onPhone(), 'sl-anna');
|
|
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
|
|
|
|
await expectLater(
|
|
setWith(const Uuid().v4obj()),
|
|
refusedAsOidc(OidcRefusal.unknownAttempt),
|
|
);
|
|
await expectLater(
|
|
withClock(
|
|
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
|
() => setWith(attempt),
|
|
),
|
|
refusedAsOidc(OidcRefusal.unknownAttempt),
|
|
);
|
|
await expectLater(
|
|
setWith(annas),
|
|
refusedAsOidc(OidcRefusal.wrongCaller),
|
|
);
|
|
expect(
|
|
await idp().isKept(session, attempt: annas, caller: anna),
|
|
isTrue,
|
|
);
|
|
await setWith(attempt);
|
|
});
|
|
});
|
|
|
|
group('a new password (D20)', () {
|
|
late KeysDevice next;
|
|
|
|
setUp(() async => next = await KeysDevice.make(seed: 7));
|
|
|
|
/// The proof for a new password ([binding], else [next]'s) by
|
|
/// [keys] (the password's proof key unless given), with the
|
|
/// sign-in under [attempt].
|
|
Future<KeysOwnerProof> changeProof(
|
|
UuidValue attempt, {
|
|
KeysPassword? binding,
|
|
SimpleKeyPair? keys,
|
|
KeyProofPurpose purpose = KeyProofPurpose.newPassword,
|
|
KeysChallenge? challenge,
|
|
}) async => await device.ownerProof(
|
|
attempt,
|
|
challenge: challenge ?? await endpoints.keys.begin(onPhone()),
|
|
purpose: purpose,
|
|
digest: newKeysPasswordDigest(binding ?? next.password()),
|
|
keys: keys,
|
|
);
|
|
|
|
Future<void> changeWith(KeysOwnerProof proof, {KeysPassword? to}) =>
|
|
endpoints.keys.changePassword(
|
|
onPhone(),
|
|
proof: proof,
|
|
password: to ?? next.password(),
|
|
);
|
|
|
|
test("takes the old password's proof and a SimpleLogin sign-in: the "
|
|
'keys open with the new one alone, and every other session '
|
|
'ends', () async {
|
|
final attempt = await signInAgain(onPhone(), 'sl-anna');
|
|
|
|
await changeWith(await changeProof(attempt));
|
|
|
|
expect(await sessionsOf(anna), {annasPhone});
|
|
final challenge = await endpoints.keys.begin(onPhone());
|
|
expect(Uint8List.sublistView(challenge.salt), next.salt);
|
|
final keys = await endpoints.keys.unlock(
|
|
onPhone(),
|
|
await next.prove(challenge),
|
|
);
|
|
expect(keys.passwordKeys.toJson(), next.passwordKeys.toJson());
|
|
await expectLater(
|
|
endpoints.keys.unlock(
|
|
onPhone(),
|
|
await device.prove(await endpoints.keys.begin(onPhone())),
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
});
|
|
|
|
test('refuses a proof by another key, for another password or '
|
|
'purpose, and a password out of range, the sign-in still '
|
|
'standing', () async {
|
|
final attempt = await signInAgain(onPhone(), 'sl-anna');
|
|
|
|
for (final proof in [
|
|
await changeProof(attempt, keys: device.recoveryProofKeys),
|
|
await changeProof(
|
|
attempt,
|
|
binding: (await KeysDevice.make(seed: 9)).password(),
|
|
),
|
|
await changeProof(attempt, purpose: KeyProofPurpose.newPhrase),
|
|
]) {
|
|
await expectLater(
|
|
changeWith(proof),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
}
|
|
await expectLater(
|
|
changeWith(
|
|
await changeProof(attempt),
|
|
to: next.password().copyWith(passwordProofKey: ByteData(31)),
|
|
),
|
|
refused(PasswordRefusal.invalidSetup),
|
|
);
|
|
expect(await sessionsOf(anna), {annasPhone, annasLaptop});
|
|
await changeWith(await changeProof(attempt));
|
|
});
|
|
|
|
test('refuses without a SimpleLogin sign-in of hers, checking no '
|
|
"proof: none, another account's, past its ten minutes, or one a "
|
|
'change took', () async {
|
|
final bens = await signInAgain(signedIn(ben, bensLaptop), 'sl-ben');
|
|
final challenge = await endpoints.keys.begin(onPhone());
|
|
|
|
for (final attempt in [const Uuid().v4obj(), bens]) {
|
|
await expectLater(
|
|
changeWith(await changeProof(attempt, challenge: challenge)),
|
|
refusedAsOidc(OidcRefusal.unknownAttempt),
|
|
);
|
|
}
|
|
final annas = await signInAgain(onPhone(), 'sl-anna');
|
|
await expectLater(
|
|
withClock(
|
|
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
|
() async => await changeWith(
|
|
await changeProof(annas, challenge: challenge),
|
|
),
|
|
),
|
|
refusedAsOidc(OidcRefusal.unknownAttempt),
|
|
);
|
|
// The challenge still answers: no proof was taken.
|
|
await changeWith(await changeProof(annas, challenge: challenge));
|
|
// One change for each sign-in.
|
|
await expectLater(
|
|
changeWith(
|
|
await next.ownerProof(
|
|
annas,
|
|
challenge: await endpoints.keys.begin(onPhone()),
|
|
purpose: KeyProofPurpose.newPassword,
|
|
digest: newKeysPasswordDigest(device.password()),
|
|
),
|
|
to: device.password(),
|
|
),
|
|
refusedAsOidc(OidcRefusal.unknownAttempt),
|
|
);
|
|
});
|
|
|
|
test("counts the session's wrong proofs, as unlocking does: the "
|
|
'tenth in a row ends it', () async {
|
|
final attempt = await signInAgain(onPhone(), 'sl-anna');
|
|
|
|
for (var i = 0; i < 10; i++) {
|
|
await expectLater(
|
|
changeWith(
|
|
await changeProof(attempt, keys: device.recoveryProofKeys),
|
|
),
|
|
refused(
|
|
i < 9
|
|
? PasswordRefusal.wrongCredentials
|
|
: PasswordRefusal.sessionEnded,
|
|
),
|
|
);
|
|
}
|
|
expect(await sessionsOf(anna), {annasLaptop});
|
|
});
|
|
});
|
|
|
|
group('a lost password (D20)', () {
|
|
late KeysDevice next;
|
|
|
|
setUp(() async => next = await KeysDevice.make(seed: 7));
|
|
|
|
Future<void> resetWith(KeysOwnerProof proof) => endpoints.keys
|
|
.resetPassword(onPhone(), proof: proof, password: next.password());
|
|
|
|
test("is set anew with the recovery phrase's proof and a SimpleLogin "
|
|
'sign-in, every other session ending', () async {
|
|
final phrase = await endpoints.keys.beginPhrase(onPhone());
|
|
expect(phrase.recoveryKeys.toJson(), device.recoveryKeys.toJson());
|
|
expect(
|
|
Uint8List.sublistView(phrase.signingPublicKey),
|
|
device.signingPublicKey,
|
|
);
|
|
expect(phrase.nonce.lengthInBytes, 32);
|
|
final attempt = await signInAgain(onPhone(), 'sl-anna');
|
|
|
|
await resetWith(
|
|
await device.ownerProof(
|
|
attempt,
|
|
phrase: phrase,
|
|
purpose: KeyProofPurpose.passwordReset,
|
|
digest: newKeysPasswordDigest(next.password()),
|
|
),
|
|
);
|
|
|
|
expect(await sessionsOf(anna), {annasPhone});
|
|
await endpoints.keys.unlock(
|
|
onPhone(),
|
|
await next.prove(await endpoints.keys.begin(onPhone())),
|
|
);
|
|
});
|
|
|
|
test("refuses the password's proof key, a challenge of keys.begin, "
|
|
'and a proof for another purpose', () async {
|
|
final attempt = await signInAgain(onPhone(), 'sl-anna');
|
|
final digest = newKeysPasswordDigest(next.password());
|
|
|
|
await expectLater(
|
|
resetWith(
|
|
await device.ownerProof(
|
|
attempt,
|
|
phrase: await endpoints.keys.beginPhrase(onPhone()),
|
|
keys: device.passwordProofKeys,
|
|
purpose: KeyProofPurpose.passwordReset,
|
|
digest: digest,
|
|
),
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
await expectLater(
|
|
resetWith(
|
|
await device.ownerProof(
|
|
attempt,
|
|
challenge: await endpoints.keys.begin(onPhone()),
|
|
keys: device.recoveryProofKeys,
|
|
purpose: KeyProofPurpose.passwordReset,
|
|
digest: digest,
|
|
),
|
|
),
|
|
refused(PasswordRefusal.challengeExpired),
|
|
);
|
|
await expectLater(
|
|
resetWith(
|
|
await device.ownerProof(
|
|
attempt,
|
|
phrase: await endpoints.keys.beginPhrase(onPhone()),
|
|
purpose: KeyProofPurpose.newPassword,
|
|
digest: digest,
|
|
),
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
await resetWith(
|
|
await device.ownerProof(
|
|
attempt,
|
|
phrase: await endpoints.keys.beginPhrase(onPhone()),
|
|
purpose: KeyProofPurpose.passwordReset,
|
|
digest: digest,
|
|
),
|
|
);
|
|
});
|
|
|
|
test('is for an account with such keys alone', () async {
|
|
final bens = signedIn(ben, bensLaptop);
|
|
await expectLater(
|
|
endpoints.keys.beginPhrase(bens),
|
|
refused(PasswordRefusal.noPassword),
|
|
);
|
|
await AccountPassword.db.insertRow(
|
|
session,
|
|
passwordRowOf(
|
|
ben,
|
|
(await PasswordDevice.make()).setup(),
|
|
now: DateTime.now().toUtc(),
|
|
),
|
|
);
|
|
await expectLater(
|
|
endpoints.keys.beginPhrase(bens),
|
|
refused(PasswordRefusal.noPassword),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('a new recovery phrase (D20)', () {
|
|
late KeysDevice next;
|
|
|
|
setUp(() async => next = await KeysDevice.make(seed: 7));
|
|
|
|
Future<void> setPhrase(
|
|
KeysOwnerProof proof, {
|
|
WrappedKeys? recoveryKeys,
|
|
}) => endpoints.keys.setRecoveryKeys(
|
|
onPhone(),
|
|
proof: proof,
|
|
recoveryKeys: recoveryKeys ?? next.recoveryKeys,
|
|
recoveryProofKey: ByteData.sublistView(next.recoveryProofKey),
|
|
);
|
|
|
|
Future<KeysOwnerProof> phraseProof(UuidValue attempt) async =>
|
|
await device.ownerProof(
|
|
attempt,
|
|
challenge: await endpoints.keys.begin(onPhone()),
|
|
purpose: KeyProofPurpose.newPhrase,
|
|
digest: newPhraseDigest(
|
|
next.recoveryKeys,
|
|
ByteData.sublistView(next.recoveryProofKey),
|
|
),
|
|
);
|
|
|
|
test("takes the password's proof and a SimpleLogin sign-in, the old "
|
|
'phrase proving nothing then, the sessions staying', () async {
|
|
await setPhrase(
|
|
await phraseProof(await signInAgain(onPhone(), 'sl-anna')),
|
|
);
|
|
|
|
final phrase = await endpoints.keys.beginPhrase(onPhone());
|
|
expect(phrase.recoveryKeys.toJson(), next.recoveryKeys.toJson());
|
|
expect(await sessionsOf(anna), {annasPhone, annasLaptop});
|
|
final again = await signInAgain(onPhone(), 'sl-anna');
|
|
final digest = newKeysPasswordDigest(next.password());
|
|
await expectLater(
|
|
endpoints.keys.resetPassword(
|
|
onPhone(),
|
|
proof: await device.ownerProof(
|
|
again,
|
|
phrase: phrase,
|
|
purpose: KeyProofPurpose.passwordReset,
|
|
digest: digest,
|
|
),
|
|
password: next.password(),
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
await endpoints.keys.resetPassword(
|
|
onPhone(),
|
|
proof: await next.ownerProof(
|
|
again,
|
|
phrase: await endpoints.keys.beginPhrase(onPhone()),
|
|
purpose: KeyProofPurpose.passwordReset,
|
|
digest: digest,
|
|
),
|
|
password: next.password(),
|
|
);
|
|
});
|
|
|
|
test('refuses a phrase out of range, or a proof for another '
|
|
'one', () async {
|
|
final attempt = await signInAgain(onPhone(), 'sl-anna');
|
|
|
|
await expectLater(
|
|
setPhrase(
|
|
await phraseProof(attempt),
|
|
recoveryKeys: next.recoveryKeys.copyWith(nonce: ByteData(12)),
|
|
),
|
|
refused(PasswordRefusal.invalidSetup),
|
|
);
|
|
await expectLater(
|
|
setPhrase(
|
|
await phraseProof(attempt),
|
|
recoveryKeys: device.recoveryKeys,
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
await setPhrase(await phraseProof(attempt));
|
|
});
|
|
});
|
|
|
|
group('deleting the account (D20)', () {
|
|
Future<KeysOwnerProof> deletionProof(
|
|
UuidValue attempt, {
|
|
KeyProofPurpose purpose = KeyProofPurpose.deleteAccount,
|
|
}) async => await device.ownerProof(
|
|
attempt,
|
|
challenge: await endpoints.keys.begin(onPhone()),
|
|
purpose: purpose,
|
|
);
|
|
|
|
test("takes the password's proof and a SimpleLogin sign-in", () async {
|
|
await endpoints.account.delete(
|
|
onPhone(),
|
|
keysProof: await deletionProof(
|
|
await signInAgain(onPhone(), 'sl-anna'),
|
|
),
|
|
);
|
|
|
|
expect(await AuthUser.db.findById(session, anna), isNull);
|
|
});
|
|
|
|
test('refuses a session alone, the proof of a password that signs '
|
|
'in, a proof for another purpose, and one without a sign-in, '
|
|
'keeping the account', () async {
|
|
await expectLater(
|
|
endpoints.account.delete(onPhone()),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
await expectLater(
|
|
endpoints.account.delete(
|
|
onPhone(),
|
|
proof: DeletionProof(
|
|
challenge: const Uuid().v4obj(),
|
|
clientValue: ByteData(384),
|
|
clientProof: ByteData(32),
|
|
totpCode: '000000',
|
|
instanceUrl: testInstanceUrl,
|
|
),
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
await expectLater(
|
|
endpoints.account.delete(
|
|
onPhone(),
|
|
keysProof: await deletionProof(const Uuid().v4obj()),
|
|
),
|
|
refusedAsOidc(OidcRefusal.unknownAttempt),
|
|
);
|
|
await expectLater(
|
|
endpoints.account.delete(
|
|
onPhone(),
|
|
keysProof: await deletionProof(
|
|
await signInAgain(onPhone(), 'sl-anna'),
|
|
purpose: KeyProofPurpose.newPassword,
|
|
),
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
expect(await AuthUser.db.findById(session, anna), isNotNull);
|
|
});
|
|
});
|
|
|
|
group('the ways in', () {
|
|
test('say the account has keys, and no password that signs in, nor '
|
|
'an authenticator to add', () async {
|
|
// Where password sign-in is offered, which takes mail too.
|
|
final pod = session.serverpod;
|
|
usePasswordKeys(pod, testPasswordKeys);
|
|
EmailCodes.of(pod).mailer = Mailbox();
|
|
addTearDown(() => EmailCodes.of(pod).mailer = null);
|
|
final ways = await endpoints.account.signInWays(onPhone());
|
|
|
|
expect(ways.keys, isTrue);
|
|
expect(ways.password, isFalse);
|
|
expect(ways.passwordOnly, isFalse);
|
|
await expectLater(
|
|
endpoints.authenticator.enrol(
|
|
onPhone(),
|
|
name: 'Phone',
|
|
label: 'anna',
|
|
),
|
|
refused(PasswordRefusal.noPassword),
|
|
);
|
|
});
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _deleteAll(Session session) async {
|
|
await OidcAttempt.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
|
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
await deleteAll(session);
|
|
}
|