An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
307 lines
11 KiB
Dart
307 lines
11 KiB
Dart
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<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('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),
|
|
);
|
|
});
|
|
});
|
|
},
|
|
);
|
|
}
|