Whoever knew the password could guess codes at 10 an hour forever,
unnoticed, each guess checked against every authenticator (the second
reading's M4). Every code an account's second factor refuses after a
right password (sign-in, a phrase's reset, a change) or from a signed-in
device (removing an authenticator, new recovery codes, the code from one
confirmed before) now counts, per account, from every network; after
every tenth in a row no code is taken (codesWait, saying until when), an
hour, then twice as long each time, a day at the most (longestCodeWait,
second_factor_waits.dart): at a day, ten guesses a day still have about a
1 % chance a year against one authenticator, so the first wait mails the
owner that someone knows their password. The instance keeps no address,
so sign-in and the phrase's reset now send the address the device typed
(and its language), used only for that mail after its hash is checked
against the account's. A code accepted clears it all.
Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits"
bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT
NULL) with a unique index on "authUserId": a new table, nothing
touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
1138 lines
38 KiB
Dart
1138 lines
38 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:clock/clock.dart';
|
|
import 'package:halkyon_base_server/base.dart';
|
|
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
|
|
import 'package:halkyon_base_server/src/register/instance_api.dart';
|
|
import 'package:halkyon_base_server/src/register/register_protocol.dart';
|
|
import 'package:halkyon_base_server/src/register/remote_register.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/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/password_device.dart';
|
|
import '../test_tools/serverpod_test_tools.dart';
|
|
|
|
const _secret = 'a shared secret only the instance knows';
|
|
final _todo = Uri.parse('http://todo.test:8082');
|
|
final _train = Uri.parse('http://train.test:8082');
|
|
|
|
/// Keeps every code mailed, in place of a mail service.
|
|
final class _Mailbox implements CodeMailer {
|
|
final sent = <({String to, String code})>[];
|
|
|
|
@override
|
|
Future<void> send(
|
|
Session session, {
|
|
required String to,
|
|
required String code,
|
|
required String language,
|
|
}) async => sent.add((to: to, code: code));
|
|
|
|
@override
|
|
Future<void> sendResetNotice(
|
|
Session session, {
|
|
required String to,
|
|
required AccountResetKind kind,
|
|
required DateTime takesEffectAt,
|
|
required String language,
|
|
}) async {}
|
|
|
|
@override
|
|
Future<void> sendCodesWaitNotice(
|
|
Session session, {
|
|
required String to,
|
|
required DateTime until,
|
|
required String language,
|
|
}) async {}
|
|
|
|
String codeFor(String address) =>
|
|
sent.lastWhere((mail) => mail.to == address).code;
|
|
}
|
|
|
|
/// The members as the register tells them, each answering as [answers]
|
|
/// says (confirming by default).
|
|
final class _Members implements MemberNotifier {
|
|
final told = <(Uri, UuidValue)>[];
|
|
|
|
/// The sessions ended they were told of: member, account, moment, kept.
|
|
final sessionsTold = <(Uri, UuidValue, DateTime, UuidValue?)>[];
|
|
bool Function(Uri member) answers = (_) => true;
|
|
|
|
@override
|
|
Future<bool> accountGone(Uri member, UuidValue account) async {
|
|
told.add((member, account));
|
|
return answers(member);
|
|
}
|
|
|
|
@override
|
|
Future<bool> sessionsEnded(
|
|
Uri member,
|
|
UuidValue account, {
|
|
required DateTime endedBefore,
|
|
UuidValue? keepSession,
|
|
}) async {
|
|
sessionsTold.add((member, account, endedBefore, keepSession));
|
|
return answers(member);
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
withServerpod(
|
|
'The register answering its members',
|
|
// Mailing codes counts attempts in transactions of their own
|
|
// (Serverpod's rate limiter), which the test's rollback can't wrap.
|
|
rollbackDatabase: RollbackDatabase.disabled,
|
|
(sessionBuilder, endpoints) {
|
|
late Session session;
|
|
late Serverpod pod;
|
|
late _Mailbox mailbox;
|
|
late _Members members;
|
|
|
|
/// The register as a member's server asks it, over HTTP into this
|
|
/// server's answers.
|
|
late RemoteRegister remote;
|
|
|
|
/// Identified, signing in with ben@example.org and GitHub 1001.
|
|
late UuidValue ben;
|
|
|
|
setUp(() async {
|
|
useBase(sessionBuilder, accounts: HoldsRegister(members: [_todo]));
|
|
session = sessionBuilder.build();
|
|
pod = session.serverpod;
|
|
await _deleteAll(session);
|
|
useInstanceSecret(pod, _secret);
|
|
mailbox = _Mailbox();
|
|
EmailCodes.of(pod).mailer = mailbox;
|
|
members = _Members();
|
|
MemberNotifier.use(pod, members);
|
|
remote = RemoteRegister(
|
|
Uri.parse('http://learn.test:8082'),
|
|
secret: _secret,
|
|
client: () => MockClient((request) async {
|
|
final answer = await answerMember(
|
|
session,
|
|
request.url.pathSegments.last,
|
|
request.body,
|
|
authorization: request.headers['authorization'],
|
|
);
|
|
return http.Response.bytes(
|
|
utf8.encode(jsonEncode(answer.body)),
|
|
answer.status,
|
|
);
|
|
}),
|
|
);
|
|
ben = (await AuthServices.instance.authUsers.create(
|
|
session,
|
|
scopes: {identified},
|
|
)).id;
|
|
await EmailIdentity.db.insertRow(
|
|
session,
|
|
EmailIdentity(
|
|
authUserId: ben,
|
|
addressHash: addressHash(pod, 'ben@example.org'),
|
|
createdAt: clock.now(),
|
|
),
|
|
);
|
|
await GitHubAccount.db.insertRow(
|
|
session,
|
|
GitHubAccount(userIdentifier: '1001', authUserId: ben),
|
|
);
|
|
});
|
|
tearDown(() async {
|
|
EmailCodes.of(pod).mailer = null;
|
|
MemberNotifier.use(pod, null);
|
|
useInstanceSecret(pod, null);
|
|
await _deleteAll(sessionBuilder.build());
|
|
});
|
|
|
|
Future<InstanceAnswer> ask(
|
|
String operation,
|
|
Map<String, Object?> arguments, {
|
|
String? authorization = 'Bearer $_secret',
|
|
}) => answerMember(
|
|
session,
|
|
operation,
|
|
jsonEncode(arguments),
|
|
authorization: authorization,
|
|
);
|
|
|
|
Matcher refused(Object reason) => throwsA(
|
|
isA<Exception>().having(
|
|
(error) => refusalJson(error)?['reason'],
|
|
'reason',
|
|
(reason as Enum).name,
|
|
),
|
|
);
|
|
|
|
group('refuses', () {
|
|
test('a call without the instance secret, with a wrong one or in '
|
|
'another scheme, doing nothing', () async {
|
|
for (final authorization in [
|
|
null,
|
|
'',
|
|
'Bearer',
|
|
'Bearer ',
|
|
'Bearer not the secret',
|
|
'Bearer $_secret ',
|
|
'Basic $_secret',
|
|
_secret,
|
|
]) {
|
|
final answer = await ask(RegisterOperation.deleteAccount, {
|
|
'person': ben.uuid,
|
|
}, authorization: authorization);
|
|
expect(answer.status, 401, reason: authorization);
|
|
}
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
});
|
|
|
|
test(
|
|
'every call on a register without a secret, whatever is sent',
|
|
() async {
|
|
useInstanceSecret(pod, null);
|
|
|
|
for (final authorization in [null, 'Bearer ', 'Bearer null']) {
|
|
final answer = await ask(RegisterOperation.deleteAccount, {
|
|
'person': ben.uuid,
|
|
}, authorization: authorization);
|
|
expect(answer.status, 401, reason: authorization);
|
|
}
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
},
|
|
);
|
|
|
|
test('register calls on a member, which holds no register', () async {
|
|
useBase(
|
|
sessionBuilder,
|
|
accounts: MemberOfRegister(Uri.parse('http://learn.test:8082')),
|
|
);
|
|
|
|
final answer = await ask(RegisterOperation.knownAccounts, {
|
|
'people': [ben.uuid],
|
|
});
|
|
|
|
expect(answer.status, 404);
|
|
});
|
|
|
|
test('what it cannot read, and operations it does not know', () async {
|
|
for (final body in ['', 'not JSON', '[]', '"ben"', 'null']) {
|
|
final answer = await answerMember(
|
|
session,
|
|
RegisterOperation.knownAccounts,
|
|
body,
|
|
authorization: 'Bearer $_secret',
|
|
);
|
|
expect(answer.status, 400, reason: body);
|
|
}
|
|
for (final arguments in <Map<String, Object?>>[
|
|
{},
|
|
{'people': 'not a list'},
|
|
{
|
|
'people': [1, 2],
|
|
},
|
|
{
|
|
'people': ['not an id'],
|
|
},
|
|
]) {
|
|
final answer = await ask(
|
|
RegisterOperation.knownAccounts,
|
|
arguments,
|
|
);
|
|
expect(answer.status, 400, reason: '$arguments');
|
|
}
|
|
expect(
|
|
(await ask(RegisterOperation.deleteAccount, {
|
|
'person': 'not an id',
|
|
})).status,
|
|
400,
|
|
);
|
|
expect((await ask('drop-everything', {})).status, 404);
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
});
|
|
});
|
|
|
|
group('profiles', () {
|
|
test('are saved, read and fixed for a member as for this server, '
|
|
'the same handle never twice', () async {
|
|
final anna = const Uuid().v7obj();
|
|
|
|
final saved = await remote.saveProfile(
|
|
session,
|
|
anna,
|
|
handle: 'Anna',
|
|
displayName: ' Anna ',
|
|
bio: 'Learning Greek.',
|
|
links: ['https://anna.example.org'],
|
|
);
|
|
|
|
expect(saved.handle, 'Anna');
|
|
expect(saved.displayName, 'Anna');
|
|
expect(
|
|
(await remote.profileOf(session, anna))!.toJson(),
|
|
saved.toJson(),
|
|
);
|
|
expect((await profileOf(session, anna))!.toJson(), saved.toJson());
|
|
expect(
|
|
(await remote.profileByHandle(session, 'anna'))!.toJson(),
|
|
saved.toJson(),
|
|
);
|
|
expect(await remote.personWithHandle(session, 'ANNA'), anna);
|
|
expect((await remote.profilesOf(session, {anna, ben})).keys, [anna]);
|
|
expect(await remote.checkHandle(session, anna, 'Anna'), isNull);
|
|
expect(
|
|
await remote.checkHandle(session, ben, 'anna'),
|
|
ProfileRefusal.handleTaken,
|
|
);
|
|
await expectLater(
|
|
remote.saveProfile(
|
|
session,
|
|
ben,
|
|
handle: 'anna',
|
|
displayName: '',
|
|
bio: '',
|
|
links: const [],
|
|
),
|
|
refused(ProfileRefusal.handleTaken),
|
|
);
|
|
|
|
await remote.fixHandle(session, anna);
|
|
|
|
expect((await remote.profileOf(session, anna))!.handleFixed, isTrue);
|
|
await expectLater(
|
|
remote.removeProfile(session, anna),
|
|
refused(ProfileRefusal.published),
|
|
);
|
|
});
|
|
|
|
test('missing are said to be so', () async {
|
|
final nobody = const Uuid().v7obj();
|
|
|
|
expect(await remote.profileOf(session, nobody), isNull);
|
|
expect(await remote.profileByHandle(session, 'nobody'), isNull);
|
|
expect(await remote.personWithHandle(session, 'nobody'), isNull);
|
|
expect(await remote.profilesOf(session, {nobody}), isEmpty);
|
|
await expectLater(
|
|
remote.removeAvatar(session, nobody),
|
|
throwsA(isA<ProfileNotFoundException>()),
|
|
);
|
|
await expectLater(
|
|
remote.checkLinks(session, nobody),
|
|
throwsA(isA<ProfileNotFoundException>()),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('email sign-in', () {
|
|
test('is offered as the register offers it', () async {
|
|
expect(await remote.emailSignInOffered(session), isTrue);
|
|
EmailCodes.of(pod).mailer = null;
|
|
expect(await remote.emailSignInOffered(session), isFalse);
|
|
});
|
|
|
|
test("counts the codes a member asks for by its device's network, "
|
|
"not the member's", () async {
|
|
for (var i = 0; i < 10; i++) {
|
|
await remote.sendSignInCode(
|
|
session,
|
|
address: 'person$i@example.org',
|
|
language: 'en',
|
|
network: '203.0.113.7',
|
|
);
|
|
}
|
|
|
|
await expectLater(
|
|
remote.sendSignInCode(
|
|
session,
|
|
address: 'person10@example.org',
|
|
language: 'en',
|
|
network: '203.0.113.7',
|
|
),
|
|
refused(EmailSignInRefusal.tooManyCodes),
|
|
);
|
|
await remote.sendSignInCode(
|
|
session,
|
|
address: 'person10@example.org',
|
|
language: 'en',
|
|
network: '198.51.100.4',
|
|
);
|
|
expect(mailbox.sent, hasLength(11));
|
|
});
|
|
|
|
test("a code proves the address for a member's password, and a "
|
|
'wrong one refuses it, keeping nothing', () async {
|
|
usePasswordKeys(pod, testPasswordKeys);
|
|
addTearDown(() => usePasswordKeys(pod, null));
|
|
final device = await PasswordDevice.make();
|
|
final annaOnTodo = const Uuid().v7obj();
|
|
final request = await remote.sendSignInCode(
|
|
session,
|
|
address: 'Anna@example.org',
|
|
language: 'de',
|
|
network: '203.0.113.7',
|
|
);
|
|
|
|
await expectLater(
|
|
remote.setPassword(
|
|
session,
|
|
person: annaOnTodo,
|
|
setup: device.setup(),
|
|
request: request,
|
|
code: 'AAAA-AAAA',
|
|
),
|
|
refused(EmailSignInRefusal.wrongCode),
|
|
);
|
|
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
|
|
await remote.setPassword(
|
|
session,
|
|
person: annaOnTodo,
|
|
setup: device.setup(),
|
|
request: request,
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
);
|
|
expect(
|
|
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
|
|
contains(identified.name),
|
|
);
|
|
expect(await remote.knownAccounts(session, {annaOnTodo, ben}), {
|
|
annaOnTodo,
|
|
ben,
|
|
});
|
|
});
|
|
});
|
|
|
|
group('GitHub', () {
|
|
test("links a member's person, who becomes an account under the "
|
|
'same ID, found by it afterwards', () async {
|
|
final annaOnTodo = const Uuid().v7obj();
|
|
|
|
await remote.linkGitHub(
|
|
session,
|
|
person: annaOnTodo,
|
|
gitHubUserId: '2002',
|
|
);
|
|
|
|
expect(await remote.accountWithGitHub(session, '2002'), annaOnTodo);
|
|
expect(await remote.accountWithGitHub(session, '1001'), ben);
|
|
expect(await remote.accountWithGitHub(session, '3003'), isNull);
|
|
expect(
|
|
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
|
|
contains(identified.name),
|
|
);
|
|
});
|
|
|
|
test(
|
|
"refuses another person's GitHub account, keeping nothing",
|
|
() async {
|
|
final annaOnTodo = const Uuid().v7obj();
|
|
|
|
await expectLater(
|
|
remote.linkGitHub(
|
|
session,
|
|
person: annaOnTodo,
|
|
gitHubUserId: '1001',
|
|
),
|
|
refused(GitHubSignInRefusal.takenByAnotherPerson),
|
|
);
|
|
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('passwords (ADR 0017)', () {
|
|
late DateTime now;
|
|
Future<T> atNow<T>(Future<T> Function() body) =>
|
|
withClock(Clock(() => now), body);
|
|
|
|
setUp(() {
|
|
usePasswordKeys(pod, testPasswordKeys);
|
|
now = DateTime.utc(2026, 10, 20, 12);
|
|
});
|
|
|
|
test('are offered as the register offers them', () async {
|
|
expect(await remote.passwordSignInOffered(session), isTrue);
|
|
usePasswordKeys(pod, null);
|
|
expect(await remote.passwordSignInOffered(session), isFalse);
|
|
await expectLater(
|
|
remote.beginPasswordSignIn(
|
|
session,
|
|
address: 'ben@example.org',
|
|
network: '203.0.113.7',
|
|
),
|
|
refused(PasswordRefusal.unavailable),
|
|
);
|
|
});
|
|
|
|
test("cross for a member's device, every operation, with what the "
|
|
'register refuses', () async {
|
|
final device = await PasswordDevice.make();
|
|
await atNow(() async {
|
|
final annaOnTodo = const Uuid().v7obj();
|
|
final request = await remote.sendSignInCode(
|
|
session,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
network: '203.0.113.7',
|
|
);
|
|
await remote.setPassword(
|
|
session,
|
|
person: annaOnTodo,
|
|
setup: device.setup(),
|
|
request: request,
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
);
|
|
expect(
|
|
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
|
|
contains(identified.name),
|
|
);
|
|
await expectLater(
|
|
remote.setPassword(
|
|
session,
|
|
person: ben,
|
|
setup: device.setup().copyWith(salt: ByteData(3)),
|
|
),
|
|
refused(PasswordRefusal.invalidSetup),
|
|
);
|
|
|
|
final enrolment = await remote.enrolAuthenticator(
|
|
session,
|
|
person: annaOnTodo,
|
|
name: 'Phone',
|
|
label: 'anna@example.org',
|
|
);
|
|
final codes = await remote.confirmAuthenticator(
|
|
session,
|
|
person: annaOnTodo,
|
|
authenticator: enrolment.authenticator,
|
|
code: codeOf(enrolment),
|
|
);
|
|
expect(codes, hasLength(recoveryCodeCount));
|
|
expect(
|
|
(await remote.authenticatorsOf(session, annaOnTodo)).single.id,
|
|
enrolment.authenticator,
|
|
);
|
|
final spare = await remote.enrolAuthenticator(
|
|
session,
|
|
person: annaOnTodo,
|
|
name: 'Spare',
|
|
label: '',
|
|
);
|
|
await expectLater(
|
|
remote.confirmAuthenticator(
|
|
session,
|
|
person: annaOnTodo,
|
|
authenticator: spare.authenticator,
|
|
code: codeOf(spare),
|
|
),
|
|
refused(PasswordRefusal.wrongExistingCode),
|
|
);
|
|
now = now.add(totpPeriod);
|
|
expect(
|
|
await remote.confirmAuthenticator(
|
|
session,
|
|
person: annaOnTodo,
|
|
authenticator: spare.authenticator,
|
|
code: codeOf(spare),
|
|
existingCode: codeOf(enrolment),
|
|
),
|
|
isEmpty,
|
|
);
|
|
now = now.add(totpPeriod);
|
|
await remote.removeAuthenticator(
|
|
session,
|
|
person: annaOnTodo,
|
|
authenticator: spare.authenticator,
|
|
code: codeOf(enrolment),
|
|
);
|
|
final ways = await remote.signInWaysOf(session, annaOnTodo);
|
|
expect(ways.password && ways.authenticator, isTrue);
|
|
|
|
now = now.add(totpPeriod);
|
|
final challenge = await remote.beginPasswordSignIn(
|
|
session,
|
|
address: 'anna@example.org',
|
|
network: '203.0.113.7',
|
|
);
|
|
final answer = device.answer(challenge);
|
|
final proof = await remote.completePasswordSignIn(
|
|
session,
|
|
challenge: challenge.challenge,
|
|
clientValue: answer.clientValue,
|
|
clientProof: answer.clientProof,
|
|
totpCode: codeOf(enrolment, at: now),
|
|
host: testHost,
|
|
);
|
|
expect(proof.account, annaOnTodo);
|
|
expect(answer.verifiesServer(proof.serverProof), isTrue);
|
|
expect(proof.passwordKeys.toJson(), device.passwordKeys.toJson());
|
|
final again = await remote.beginPasswordSignIn(
|
|
session,
|
|
address: 'anna@example.org',
|
|
network: '203.0.113.7',
|
|
);
|
|
final againAnswer = device.answer(again);
|
|
await expectLater(
|
|
remote.completePasswordSignIn(
|
|
session,
|
|
challenge: again.challenge,
|
|
clientValue: againAnswer.clientValue,
|
|
clientProof: againAnswer.clientProof,
|
|
totpCode: codeOf(enrolment, at: now),
|
|
host: testHost,
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
|
|
now = now.add(totpPeriod);
|
|
expect(
|
|
await remote.newRecoveryCodes(
|
|
session,
|
|
person: annaOnTodo,
|
|
code: codeOf(enrolment, at: now),
|
|
),
|
|
hasLength(recoveryCodeCount),
|
|
);
|
|
|
|
final keyChallenge = await remote.beginKeyProof(
|
|
session,
|
|
address: 'anna@example.org',
|
|
network: '203.0.113.7',
|
|
);
|
|
expect(
|
|
keyChallenge.recoveryKeys.toJson(),
|
|
device.recoveryKeys.toJson(),
|
|
);
|
|
now = now.add(totpPeriod);
|
|
final newPassword = device.newPassword();
|
|
expect(
|
|
await remote.resetPasswordWithPhrase(
|
|
session,
|
|
challenge: keyChallenge.challenge,
|
|
signature: await device.sign(
|
|
keyChallenge,
|
|
password: newPassword,
|
|
),
|
|
password: newPassword,
|
|
totpCode: codeOf(enrolment, at: now),
|
|
host: testHost,
|
|
),
|
|
(account: annaOnTodo, endedBefore: now),
|
|
);
|
|
// Every member is told to end the account's sessions too.
|
|
expect(members.sessionsTold.single, (_todo, annaOnTodo, now, null));
|
|
|
|
final forReset = await remote.beginPasswordSignIn(
|
|
session,
|
|
address: 'anna@example.org',
|
|
network: '203.0.113.7',
|
|
);
|
|
final resetAnswer = device.answer(forReset);
|
|
final resetRequest = await remote.sendSignInCode(
|
|
session,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
network: '203.0.113.7',
|
|
);
|
|
final pending = await remote.resetAuthenticator(
|
|
session,
|
|
challenge: forReset.challenge,
|
|
clientValue: resetAnswer.clientValue,
|
|
clientProof: resetAnswer.clientProof,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
request: resetRequest,
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
host: testHost,
|
|
);
|
|
expect(pending.takesEffectAt, now.add(resetDelay));
|
|
|
|
final startRequest = await remote.sendSignInCode(
|
|
session,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
network: '203.0.113.7',
|
|
);
|
|
await remote.startAccountReset(
|
|
session,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
request: startRequest,
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
);
|
|
now = now.add(resetDelay);
|
|
final fresh = await PasswordDevice.make(seed: 99);
|
|
final completeRequest = await remote.sendSignInCode(
|
|
session,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
network: '203.0.113.7',
|
|
);
|
|
expect(
|
|
await remote.completeAccountReset(
|
|
session,
|
|
address: 'anna@example.org',
|
|
request: completeRequest,
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
setup: fresh.setup(),
|
|
),
|
|
(account: annaOnTodo, endedBefore: now),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
group('changes while signed in (ADR 0017)', () {
|
|
late DateTime now;
|
|
Future<T> atNow<T>(Future<T> Function() body) =>
|
|
withClock(Clock(() => now), body);
|
|
|
|
setUp(() {
|
|
usePasswordKeys(pod, testPasswordKeys);
|
|
now = DateTime.utc(2026, 10, 20, 12);
|
|
});
|
|
|
|
test(
|
|
"cross for a member's device: the password, keeping the "
|
|
"member's session, the phrase's wrapping, and a reset's date",
|
|
() async {
|
|
final device = await PasswordDevice.make();
|
|
await atNow(() async {
|
|
final annaOnTodo = const Uuid().v7obj();
|
|
final annasPhone = const Uuid().v7obj();
|
|
final request = await remote.sendSignInCode(
|
|
session,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
network: '203.0.113.7',
|
|
);
|
|
await remote.setPassword(
|
|
session,
|
|
person: annaOnTodo,
|
|
setup: device.setup(),
|
|
request: request,
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
);
|
|
final enrolment = await remote.enrolAuthenticator(
|
|
session,
|
|
person: annaOnTodo,
|
|
name: 'Phone',
|
|
label: 'anna@example.org',
|
|
);
|
|
await remote.confirmAuthenticator(
|
|
session,
|
|
person: annaOnTodo,
|
|
authenticator: enrolment.authenticator,
|
|
code: codeOf(enrolment),
|
|
);
|
|
now = now.add(totpPeriod);
|
|
|
|
final challenge = await remote.beginPasswordChange(
|
|
session,
|
|
person: annaOnTodo,
|
|
);
|
|
final answer = device.answer(challenge);
|
|
await remote.changePassword(
|
|
session,
|
|
person: annaOnTodo,
|
|
challenge: challenge.challenge,
|
|
clientValue: answer.clientValue,
|
|
clientProof: answer.clientProof,
|
|
totpCode: codeOf(enrolment),
|
|
password: device.newPassword(),
|
|
keepSession: annasPhone,
|
|
host: testHost,
|
|
);
|
|
expect(members.sessionsTold.single, (
|
|
_todo,
|
|
annaOnTodo,
|
|
now,
|
|
annasPhone,
|
|
));
|
|
|
|
now = now.add(totpPeriod);
|
|
final fresh = WrappedKeys(
|
|
nonce: ByteData(24),
|
|
cipherText: ByteData(wrappedKeysLength),
|
|
);
|
|
Future<void> newPhrase() async {
|
|
final challenge = await remote.beginPasswordChange(
|
|
session,
|
|
person: annaOnTodo,
|
|
);
|
|
final answer = device.answer(challenge);
|
|
await remote.setRecoveryKeys(
|
|
session,
|
|
person: annaOnTodo,
|
|
challenge: challenge.challenge,
|
|
clientValue: answer.clientValue,
|
|
clientProof: answer.clientProof,
|
|
totpCode: codeOf(enrolment),
|
|
recoveryKeys: fresh,
|
|
recoveryProofKey: ByteData.sublistView(
|
|
device.recoveryProofKey,
|
|
),
|
|
host: testHost,
|
|
);
|
|
}
|
|
|
|
await newPhrase();
|
|
// The same step's code again.
|
|
await expectLater(
|
|
newPhrase(),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
|
|
final start = await remote.sendSignInCode(
|
|
session,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
network: '203.0.113.7',
|
|
);
|
|
final pending = await remote.startAccountReset(
|
|
session,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
request: start,
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
);
|
|
final early = await remote.sendSignInCode(
|
|
session,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
network: '203.0.113.7',
|
|
);
|
|
await expectLater(
|
|
remote.completeAccountReset(
|
|
session,
|
|
address: 'anna@example.org',
|
|
request: early,
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
setup: device.setup(),
|
|
),
|
|
throwsA(
|
|
isA<PasswordRefusedException>().having(
|
|
(error) => error.takesEffectAt,
|
|
'takesEffectAt',
|
|
pending.takesEffectAt,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
group('ways in without a working password (ADR 0017)', () {
|
|
late DateTime now;
|
|
Future<T> atNow<T>(Future<T> Function() body) =>
|
|
withClock(Clock(() => now), body);
|
|
|
|
setUp(() {
|
|
usePasswordKeys(pod, testPasswordKeys);
|
|
now = DateTime.utc(2026, 10, 20, 12);
|
|
});
|
|
|
|
test("cross for a member's device: signing up with a code, and a "
|
|
'setup never finished signing in with the password and a code, '
|
|
'with what the register refuses', () async {
|
|
final device = await PasswordDevice.make();
|
|
await atNow(() async {
|
|
Future<UuidValue> code() => remote.sendSignInCode(
|
|
session,
|
|
address: 'carla@example.org',
|
|
language: 'en',
|
|
network: '203.0.113.7',
|
|
);
|
|
final carla = await remote.signUpWithCode(
|
|
session,
|
|
request: await code(),
|
|
code: mailbox.codeFor('carla@example.org'),
|
|
setup: device.setup(),
|
|
);
|
|
expect(
|
|
(await AuthUser.db.findById(session, carla))!.scopeNames,
|
|
contains(identified.name),
|
|
);
|
|
await expectLater(
|
|
remote.signUpWithCode(
|
|
session,
|
|
request: await code(),
|
|
code: mailbox.codeFor('carla@example.org'),
|
|
setup: device.setup(),
|
|
),
|
|
refused(PasswordRefusal.passwordExists),
|
|
);
|
|
|
|
final challenge = await remote.beginPasswordSignIn(
|
|
session,
|
|
address: 'carla@example.org',
|
|
network: '203.0.113.7',
|
|
);
|
|
final answer = device.answer(challenge);
|
|
final proof = await remote.completePasswordSignIn(
|
|
session,
|
|
challenge: challenge.challenge,
|
|
clientValue: answer.clientValue,
|
|
clientProof: answer.clientProof,
|
|
request: await code(),
|
|
code: mailbox.codeFor('carla@example.org'),
|
|
host: testHost,
|
|
);
|
|
expect(proof.account, carla);
|
|
expect(proof.needsAuthenticator, isTrue);
|
|
});
|
|
});
|
|
});
|
|
|
|
group('deleting an account', () {
|
|
test('deletes it here and tells every member, keeping no notice '
|
|
'once they confirmed', () async {
|
|
await remote.deleteAccount(session, ben);
|
|
|
|
expect(await AuthUser.db.findById(session, ben), isNull);
|
|
expect(await EmailIdentity.db.count(session), 0);
|
|
expect(await GitHubAccount.db.count(session), 0);
|
|
expect(members.told, [(_todo, ben)]);
|
|
expect(await AccountDeletionNotice.db.count(session), 0);
|
|
expect(await remote.knownAccounts(session, {ben}), isEmpty);
|
|
});
|
|
|
|
test(
|
|
'tells a member that does not answer again until it does',
|
|
() async {
|
|
members.answers = (member) => member != _todo;
|
|
|
|
await remote.deleteAccount(session, ben);
|
|
|
|
expect(await AuthUser.db.findById(session, ben), isNull);
|
|
final kept = await AccountDeletionNotice.db.find(session);
|
|
expect(kept.single.account, ben);
|
|
expect(kept.single.member, '$_todo');
|
|
expect(kept.single.attempts, 1);
|
|
expect(kept.single.lastAttemptAt, isNotNull);
|
|
|
|
expect(await deliverDeletionNotices(session), 0);
|
|
expect(
|
|
(await AccountDeletionNotice.db.find(session)).single.attempts,
|
|
2,
|
|
);
|
|
|
|
members.answers = (_) => true;
|
|
expect(await deliverDeletionNotices(session), 1);
|
|
expect(await AccountDeletionNotice.db.count(session), 0);
|
|
expect(members.told, [(_todo, ben), (_todo, ben), (_todo, ben)]);
|
|
},
|
|
);
|
|
|
|
test('tells the members of an account this server no longer has, '
|
|
'as one may still hold its data', () async {
|
|
final gone = const Uuid().v7obj();
|
|
|
|
await remote.deleteAccount(session, gone);
|
|
|
|
expect(members.told, [(_todo, gone)]);
|
|
});
|
|
|
|
test('tells nobody of an anonymous person, a member never having '
|
|
'had them', () async {
|
|
final anna = await newAnonymousPerson(session);
|
|
|
|
await remote.deleteAccount(session, anna);
|
|
|
|
expect(await AuthUser.db.findById(session, anna), isNull);
|
|
expect(members.told, isEmpty);
|
|
expect(await AccountDeletionNotice.db.count(session), 0);
|
|
});
|
|
|
|
test(
|
|
'drops the notices of a member the operator no longer names',
|
|
() async {
|
|
members.answers = (_) => false;
|
|
useBase(
|
|
sessionBuilder,
|
|
accounts: HoldsRegister(members: [_todo, _train]),
|
|
);
|
|
await remote.deleteAccount(session, ben);
|
|
expect(await AccountDeletionNotice.db.count(session), 2);
|
|
|
|
useBase(sessionBuilder, accounts: HoldsRegister(members: [_train]));
|
|
await deliverDeletionNotices(session);
|
|
|
|
expect(
|
|
(await AccountDeletionNotice.db.find(session)).single.member,
|
|
'$_train',
|
|
);
|
|
useBase(sessionBuilder);
|
|
await deliverDeletionNotices(session);
|
|
expect(await AccountDeletionNotice.db.count(session), 0);
|
|
},
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
group('A member asking the register', () {
|
|
late List<http.Request> requests;
|
|
|
|
/// A register answering every request with [status] and [body].
|
|
RemoteRegister answering(int status, String body) {
|
|
requests = [];
|
|
return RemoteRegister(
|
|
Uri.parse('http://learn.test:8082'),
|
|
secret: _secret,
|
|
client: () => MockClient((request) async {
|
|
requests.add(request);
|
|
return http.Response(body, status);
|
|
}),
|
|
);
|
|
}
|
|
|
|
test(
|
|
'posts JSON to the operation with the secret as its bearer token',
|
|
() async {
|
|
final account = const Uuid().v7obj();
|
|
final register = answering(200, '{"account": "${account.uuid}"}');
|
|
|
|
expect(await register.accountWithGitHub(_noSession, '1001'), account);
|
|
final request = requests.single;
|
|
expect(request.method, 'POST');
|
|
expect(
|
|
'${request.url}',
|
|
'http://learn.test:8082/halkyon-base/register/account-with-github',
|
|
);
|
|
expect(request.headers['authorization'], 'Bearer $_secret');
|
|
expect(jsonDecode(request.body), {'gitHubUserId': '1001'});
|
|
},
|
|
);
|
|
|
|
test(
|
|
'fails as unavailable for an answer it cannot take, never guessing',
|
|
() async {
|
|
for (final (status, body) in [
|
|
(500, '{}'),
|
|
(401, '{"error": "secret"}'),
|
|
(200, 'not JSON'),
|
|
(200, '[]'),
|
|
(409, '{"refusal": {"kind": "email", "reason": "unheardOf"}}'),
|
|
(409, '{"refusal": "no"}'),
|
|
(404, '{"error": "not here"}'),
|
|
]) {
|
|
await expectLater(
|
|
answering(status, body).accountWithGitHub(_noSession, '1001'),
|
|
throwsA(isA<RegisterUnavailableException>()),
|
|
reason: '$status $body',
|
|
);
|
|
}
|
|
await expectLater(
|
|
RemoteRegister(
|
|
Uri.parse('http://learn.test:8082'),
|
|
secret: _secret,
|
|
client: () => MockClient(
|
|
(_) async => throw http.ClientException('Connection refused'),
|
|
),
|
|
).accountWithGitHub(_noSession, '1001'),
|
|
throwsA(isA<RegisterUnavailableException>()),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('The register telling a member', () {
|
|
test("posts the account to the member's path with the secret, and "
|
|
'counts only a 200 as confirmed', () async {
|
|
final account = const Uuid().v7obj();
|
|
final requests = <http.Request>[];
|
|
var status = 200;
|
|
final notifier = HttpMemberNotifier(
|
|
secret: _secret,
|
|
client: MockClient((request) async {
|
|
requests.add(request);
|
|
return http.Response('{}', status);
|
|
}),
|
|
);
|
|
|
|
expect(await notifier.accountGone(_todo, account), isTrue);
|
|
final request = requests.single;
|
|
expect(request.method, 'POST');
|
|
expect('${request.url}', 'http://todo.test:8082$accountGonePath');
|
|
expect(request.headers['authorization'], 'Bearer $_secret');
|
|
expect(jsonDecode(request.body), {'account': account.uuid});
|
|
|
|
status = 401;
|
|
expect(await notifier.accountGone(_todo, account), isFalse);
|
|
});
|
|
|
|
test('counts a failed request as not confirmed, and sends nothing '
|
|
'without a secret', () async {
|
|
final account = const Uuid().v7obj();
|
|
var sent = 0;
|
|
|
|
expect(
|
|
await HttpMemberNotifier(
|
|
secret: _secret,
|
|
client: MockClient((_) {
|
|
sent++;
|
|
return Future.error(http.ClientException('Connection refused'));
|
|
}),
|
|
).accountGone(_todo, account),
|
|
isFalse,
|
|
);
|
|
expect(
|
|
await HttpMemberNotifier(
|
|
secret: null,
|
|
client: MockClient((_) async {
|
|
sent++;
|
|
return http.Response('{}', 200);
|
|
}),
|
|
).accountGone(_todo, account),
|
|
isFalse,
|
|
);
|
|
expect(sent, 1);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// No session: [RemoteRegister] only passes it on.
|
|
final Session _noSession = _NoSession();
|
|
|
|
final class _NoSession implements Session {
|
|
@override
|
|
Object? noSuchMethod(Invocation invocation) =>
|
|
throw UnsupportedError('no session in this test');
|
|
}
|
|
|
|
Future<void> _deleteAll(Session session) async {
|
|
await SignInChallenge.db.deleteWhere(
|
|
session,
|
|
where: (t) => t.id.notEquals(null),
|
|
);
|
|
await AccountReset.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
await RecoveryCode.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
await TotpAuthenticator.db.deleteWhere(
|
|
session,
|
|
where: (t) => t.id.notEquals(null),
|
|
);
|
|
await AccountPassword.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
await AccountDeletionNotice.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
await RateLimitedRequestAttempt.db.deleteWhere(
|
|
session,
|
|
where: (t) => t.id.notEquals(null),
|
|
);
|
|
await EmailCodeRequest.db.deleteWhere(
|
|
session,
|
|
where: (t) => t.id.notEquals(null),
|
|
);
|
|
await SecretChallenge.db.deleteWhere(
|
|
session,
|
|
where: (t) => t.id.notEquals(null),
|
|
);
|
|
await EmailIdentity.db.deleteWhere(
|
|
session,
|
|
where: (t) => t.id.notEquals(null),
|
|
);
|
|
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
|
}
|