halkyon/packages/halkyon_base_server/test/integration/register/register_test.dart

1138 lines
38 KiB
Dart
Raw Normal View History

import 'dart:convert';
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
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';
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
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));
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
@override
Future<void> sendResetNotice(
Session session, {
required String to,
required AccountResetKind kind,
required DateTime takesEffectAt,
required String language,
}) async {}
fix(auth): codes wait after ten wrong in a row, and the owner is told 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>
2026-10-20 21:47:38 +02:00
@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);
},
);
});
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
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);
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
await remote.removeAuthenticator(
session,
person: annaOnTodo,
authenticator: spare.authenticator,
code: codeOf(enrolment),
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
);
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,
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
);
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,
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
),
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();
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
expect(
await remote.resetPasswordWithPhrase(
session,
challenge: keyChallenge.challenge,
signature: await device.sign(
keyChallenge,
password: newPassword,
),
password: newPassword,
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
totpCode: codeOf(enrolment, at: now),
host: testHost,
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
),
(account: annaOnTodo, endedBefore: now),
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
);
// Every member is told to end the account's sessions too.
expect(members.sessionsTold.single, (_todo, annaOnTodo, now, null));
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
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,
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
);
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),
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
);
});
});
});
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 {
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
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));
}