import 'dart:typed_data'; import 'package:halkyon_base_server/base.dart'; import 'package:halkyon_base_server/src/password/passwords.dart' show passwordRowOf; 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 and D19): the password proven with // its own proof key, a signature over the register's nonce, before the // device gets the keys wrapped under it. Every way a proof is refused, and // a session's wrong proofs ending it as ADR 0017's do. void main() { 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; /// With keys, signed in on her phone and her laptop. late UuidValue anna; late UuidValue annasPhone; late UuidValue annasLaptop; /// Without keys, signed in on his laptop. late UuidValue ben; late UuidValue bensLaptop; setUp(() async { useBase(sessionBuilder); session = sessionBuilder.build(); await deleteAll(session); 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'); }); tearDown(() => 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); Future> 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 []), (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()), ); }); 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('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), ); }); }); }, ); }