halkyon/packages/halkyon_base_server/test/integration/keys/keys_test.dart
Tizian 「ティツィアーン」 7eb1961b9f
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.

So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.

Halkyon Learn's handle dialog words the new refusal (English, German).

Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 09:17:49 +02:00

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,
platform: SimpleLoginPlatform.desktop,
);
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);
}