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

1897 lines
64 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:halkyon_base_server/src/register/sessions_ended.dart'
show sessionNoticesDelivered;
import 'package:halkyon_oidc_server/oidc.dart';
import 'package:halkyon_oidc_server/testing.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(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
import '../support/keys_device.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 EmailCodePurpose purpose,
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',
network: '203.0.113.7',
),
isNull,
);
expect(
await remote.checkHandle(
session,
ben,
'anna',
network: '203.0.113.7',
),
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("not public yet are nobody's to a member's apps, while its code "
'still finds whose a handle is (D5)', () async {
final anna = const Uuid().v7obj();
await remote.saveProfile(
session,
anna,
handle: 'Anna',
displayName: '',
bio: '',
links: const [],
network: '198.51.100.7',
);
expect(await remote.profileByHandle(session, 'anna'), isNull);
expect(await remote.personWithHandle(session, 'anna'), anna);
expect((await remote.profileOf(session, anna))!.handle, 'Anna');
});
test("count a member's answers about handles on the register, by "
"the person and the visitor's network", () async {
final anna = const Uuid().v7obj();
await remote.saveProfile(
session,
anna,
handle: 'Anna',
displayName: '',
bio: '',
links: const [],
network: '198.51.100.7',
);
await remote.checkHandle(
session,
anna,
'Anna_G',
network: '198.51.100.7',
);
final counted = await RateLimitedRequestAttempt.db.find(
session,
where: (t) => t.domain.equals('profile'),
);
expect(
counted.map((note) => (note.source, note.key)).toSet(),
{
('handle_checks', anna.uuid),
('handle_checks_per_network', '198.51.100.7'),
},
);
expect(counted, hasLength(4));
});
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',
purpose: EmailCodePurpose.signUp,
);
}
await expectLater(
remote.sendSignInCode(
session,
address: 'person10@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
),
refused(EmailSignInRefusal.tooManyCodes),
);
await remote.sendSignInCode(
session,
address: 'person10@example.org',
language: 'en',
network: '198.51.100.4',
purpose: EmailCodePurpose.signUp,
);
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',
purpose: EmailCodePurpose.signUp,
);
await expectLater(
remote.setPassword(
session,
person: annaOnTodo,
anonymousHere: true,
setup: device.setup(),
request: request,
code: 'AAAA-AAAA',
),
refused(EmailSignInRefusal.wrongCode),
);
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
// Without the member saying Anna is anonymous there, refused
// before her code is used (the full reading's M1).
await expectLater(
remote.setPassword(
session,
person: annaOnTodo,
setup: device.setup(),
request: request,
code: mailbox.codeFor('anna@example.org'),
),
refused(PasswordRefusal.wrongCredentials),
);
await remote.setPassword(
session,
person: annaOnTodo,
anonymousHere: true,
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('SimpleLogin (ADR 0020)', () {
late TestKey key;
late FakeProvider simpleLogin;
setUpAll(() => key = TestKey.generate('sl', seed: 12));
setUp(() {
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);
});
tearDown(() => useSimpleLoginApp(pod, null));
test('is offered as the register offers it', () async {
expect(await remote.simpleLoginOffered(session), isTrue);
useSimpleLogin(sessionBuilder, null);
expect(await remote.simpleLoginOffered(session), isFalse);
});
test("begins for a member's app, signed in or out, and signs in "
'the account with that identity', () async {
await OidcIdentity.db.insertRow(
session,
OidcIdentity(
authUserId: ben,
provider: simpleLoginProvider,
subject: 'sl-ben',
),
);
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-loopback',
network: '203.0.113.0/24',
);
final proof = await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-ben',
),
);
expect(start.state, startsWith('todo-loopback.'));
expect(proof.outcome, SimpleLoginOutcome.signedIn);
expect(proof.account, ben);
});
test("adds SimpleLogin to a member's person, who becomes an account "
'under the same ID', () async {
final annaOnTodo = const Uuid().v7obj();
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-scheme',
caller: annaOnTodo,
network: '203.0.113.0/24',
);
final proof = await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-anna',
),
caller: annaOnTodo,
);
expect(proof.outcome, SimpleLoginOutcome.linked);
expect(proof.account, annaOnTodo);
expect(
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
contains(identified.name),
);
expect(
(await remote.signInWaysOf(session, annaOnTodo)).simpleLogin,
isTrue,
);
});
test('passes on the handle suggested to someone new', () async {
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-loopback',
network: '203.0.113.0/24',
);
final proof = await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-carla',
claims: {'name': 'Carla Müller'},
),
);
expect(proof.outcome, SimpleLoginOutcome.newcomer);
expect(proof.account, isNull);
expect(proof.suggestedHandle, 'carla_mueller');
});
test("refuses as the register refuses, halkyon_oidc's refusals "
'crossing whole', () async {
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-loopback',
network: '203.0.113.0/24',
);
final code = simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-carla',
);
await expectLater(
remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: code,
caller: const Uuid().v7obj(),
),
throwsA(
isA<OidcRefusedException>().having(
(error) => error.reason,
'reason',
OidcRefusal.wrongCaller,
),
),
);
await expectLater(
remote.completeSimpleLogin(
session,
attempt: const Uuid().v7obj(),
code: code,
),
refused(OidcRefusal.unknownAttempt),
);
});
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
test("signs up someone new for a member's app, the handle checked "
'first, whose keys then unlock with the password', () async {
final device = await KeysDevice.make();
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-loopback',
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-carla',
),
);
expect(
await remote.checkSimpleLoginHandle(
session,
attempt: start.attempt,
handle: 'carla',
network: '203.0.113.0/24',
),
isNull,
);
final carla = await remote.signUpWithSimpleLogin(
session,
attempt: start.attempt,
handle: 'carla',
setup: device.setup(),
network: '203.0.113.0/24',
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
);
expect(
(await AuthUser.db.findById(session, carla))!.scopeNames,
{identified.name},
);
final ways = await remote.signInWaysOf(session, carla);
expect(
(ways.simpleLogin, ways.keys, ways.password),
(
true,
true,
false,
),
);
final keys = await remote.unlockKeys(
session,
carla,
await device.prove(
await remote.beginPasswordKeyProof(session, carla),
),
);
expect(
Uint8List.sublistView(keys.signingPublicKey),
device.signingPublicKey,
);
});
test('refuses a sign-up and keys as the register refuses, a '
"handle's refusals crossing whole", () async {
final device = await KeysDevice.make();
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-loopback',
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-carla',
),
);
expect(
await remote.checkSimpleLoginHandle(
session,
attempt: start.attempt,
handle: 'admin',
network: '203.0.113.0/24',
),
ProfileRefusal.handleReserved,
);
await expectLater(
remote.signUpWithSimpleLogin(
session,
attempt: start.attempt,
handle: 'admin',
setup: device.setup(),
network: '203.0.113.0/24',
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
),
refused(ProfileRefusal.handleReserved),
);
await expectLater(
remote.signUpWithSimpleLogin(
session,
attempt: const Uuid().v7obj(),
handle: 'carla',
setup: device.setup(),
network: '203.0.113.0/24',
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
),
refused(OidcRefusal.unknownAttempt),
);
await expectLater(
remote.beginPasswordKeyProof(session, ben),
refused(PasswordRefusal.noPassword),
);
final carla = await remote.signUpWithSimpleLogin(
session,
attempt: start.attempt,
handle: 'carla',
setup: device.setup(),
network: '203.0.113.0/24',
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
);
await expectLater(
remote.unlockKeys(
session,
carla,
await (await KeysDevice.make(seed: 5)).prove(
await remote.beginPasswordKeyProof(session, carla),
),
),
refused(PasswordRefusal.wrongCredentials),
);
});
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
/// A SimpleLogin sign-in of [person]'s through a member, as
/// [subject], just now: its attempt, which stands for it ten
/// minutes (D20).
Future<UuidValue> signedInAgain(
UuidValue person,
String subject,
) async {
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-loopback',
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
caller: person,
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: subject,
),
caller: person,
);
return start.attempt;
}
test(
"gives a member's account its first keys and handle, once "
'SimpleLogin was added to it, its refusals crossing whole',
() async {
final device = await KeysDevice.make();
final attempt = await signedInAgain(ben, 'sl-ben');
await expectLater(
remote.setFirstKeys(
session,
person: ben,
attempt: attempt,
setup: device.setup(),
network: '203.0.113.0/24',
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
),
refused(ProfileRefusal.handleInvalid),
);
await expectLater(
remote.setFirstKeys(
session,
person: ben,
attempt: const Uuid().v7obj(),
setup: device.setup(),
network: '203.0.113.0/24',
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
handle: 'ben',
),
refused(OidcRefusal.unknownAttempt),
);
await remote.setFirstKeys(
session,
person: ben,
attempt: attempt,
setup: device.setup(),
network: '203.0.113.0/24',
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
handle: 'ben',
);
expect((await remote.signInWaysOf(session, ben)).keys, isTrue);
expect((await remote.profileOf(session, ben))!.handle, 'ben');
await expectLater(
remote.setFirstKeys(
session,
person: ben,
attempt: await signedInAgain(ben, 'sl-ben'),
setup: device.setup(),
network: '203.0.113.0/24',
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
),
refused(PasswordRefusal.passwordExists),
);
},
);
test("changes a member's account's keys, each change with the "
"password's proof (the phrase's for a lost one) and a sign-in, "
'and deletes it alike', () async {
final device = await KeysDevice.make();
final next = await KeysDevice.make(seed: 7);
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-loopback',
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-carla',
),
);
final carla = await remote.signUpWithSimpleLogin(
session,
attempt: start.attempt,
handle: 'carla',
setup: device.setup(),
network: '203.0.113.0/24',
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
);
final keepSession = const Uuid().v7obj();
await remote.changeKeysPassword(
session,
person: carla,
proof: await device.ownerProof(
await signedInAgain(carla, 'sl-carla'),
challenge: await remote.beginPasswordKeyProof(session, carla),
purpose: KeyProofPurpose.newPassword,
digest: newKeysPasswordDigest(next.password()),
),
password: next.password(),
keepSession: keepSession,
);
// Every member is told to end the account's other sessions.
await sessionNoticesDelivered();
expect(members.sessionsTold.single.$2, carla);
expect(members.sessionsTold.single.$4, keepSession);
await remote.unlockKeys(
session,
carla,
await next.prove(
await remote.beginPasswordKeyProof(session, carla),
),
);
await remote.resetKeysPassword(
session,
person: carla,
proof: await device.ownerProof(
await signedInAgain(carla, 'sl-carla'),
phrase: await remote.beginPhraseKeyProof(session, carla),
purpose: KeyProofPurpose.passwordReset,
digest: newKeysPasswordDigest(device.password()),
),
password: device.password(),
);
await remote.setKeysRecovery(
session,
person: carla,
proof: await device.ownerProof(
await signedInAgain(carla, 'sl-carla'),
challenge: await remote.beginPasswordKeyProof(session, carla),
purpose: KeyProofPurpose.newPhrase,
digest: newPhraseDigest(
next.recoveryKeys,
ByteData.sublistView(next.recoveryProofKey),
),
),
recoveryKeys: next.recoveryKeys,
recoveryProofKey: ByteData.sublistView(next.recoveryProofKey),
);
expect(
(await remote.beginPhraseKeyProof(
session,
carla,
)).recoveryKeys.toJson(),
next.recoveryKeys.toJson(),
);
final deletion = await signedInAgain(carla, 'sl-carla');
await expectLater(
remote.deleteAccount(session, carla),
refused(PasswordRefusal.wrongCredentials),
);
await remote.deleteAccount(
session,
carla,
keysProof: await device.ownerProof(
deletion,
challenge: await remote.beginPasswordKeyProof(session, carla),
purpose: KeyProofPurpose.deleteAccount,
),
);
expect(await AuthUser.db.findById(session, carla), isNull);
});
feat(base): mending a broken SimpleLogin link with the handle and the recovery phrase (ADR 0020, D7) Someone whose SimpleLogin identity is new to the register but who has an account ("I already have one") names their handle and proves the account's recovery phrase; the new identity takes the old one's place. - `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the phrase's key, the public keys and a nonce, for the account with that handle (whatever its case). Only an account with keys and a SimpleLogin identity is mended, a link it had, as nobody adds SimpleLogin signed out (D10); a handle nobody has, or any other account's, gets a decoy from `signInDecoyKey`, the same each time. At most 10 an hour per handle and 30 per network. - `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`, binding the attempt (`mendLinkDigest`, on both sides), so a proof relayed from another sign-in links nobody's identity but the device's own; checked for the waiting identity first, which leaves the proof unspent. Every session of the account ends, in every service, and the sign-in stands ten minutes for a change, such as a lost password. - SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys (`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a SimpleLogin app and without it (`checkSimpleLoginMending`). - Members ask the register for both; `RemoteRegister` and the instance API carry them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:26:58 +02:00
test(
"mends a member's account's broken link with its handle and "
'recovery phrase, a decoy and its refusal crossing whole',
() async {
usePasswordKeys(pod, testPasswordKeys);
final device = await KeysDevice.make();
final first = await remote.beginSimpleLogin(
session,
marker: 'todo-loopback',
feat(base): mending a broken SimpleLogin link with the handle and the recovery phrase (ADR 0020, D7) Someone whose SimpleLogin identity is new to the register but who has an account ("I already have one") names their handle and proves the account's recovery phrase; the new identity takes the old one's place. - `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the phrase's key, the public keys and a nonce, for the account with that handle (whatever its case). Only an account with keys and a SimpleLogin identity is mended, a link it had, as nobody adds SimpleLogin signed out (D10); a handle nobody has, or any other account's, gets a decoy from `signInDecoyKey`, the same each time. At most 10 an hour per handle and 30 per network. - `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`, binding the attempt (`mendLinkDigest`, on both sides), so a proof relayed from another sign-in links nobody's identity but the device's own; checked for the waiting identity first, which leaves the proof unspent. Every session of the account ends, in every service, and the sign-in stands ten minutes for a change, such as a lost password. - SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys (`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a SimpleLogin app and without it (`checkSimpleLoginMending`). - Members ask the register for both; `RemoteRegister` and the instance API carry them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:26:58 +02:00
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
session,
attempt: first.attempt,
code: simpleLogin.authorize(
first.authorizationUrl,
subject: 'sl-dora-old',
),
);
final dora = await remote.signUpWithSimpleLogin(
session,
attempt: first.attempt,
handle: 'Dora',
setup: device.setup(),
network: '203.0.113.0/24',
feat(base): mending a broken SimpleLogin link with the handle and the recovery phrase (ADR 0020, D7) Someone whose SimpleLogin identity is new to the register but who has an account ("I already have one") names their handle and proves the account's recovery phrase; the new identity takes the old one's place. - `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the phrase's key, the public keys and a nonce, for the account with that handle (whatever its case). Only an account with keys and a SimpleLogin identity is mended, a link it had, as nobody adds SimpleLogin signed out (D10); a handle nobody has, or any other account's, gets a decoy from `signInDecoyKey`, the same each time. At most 10 an hour per handle and 30 per network. - `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`, binding the attempt (`mendLinkDigest`, on both sides), so a proof relayed from another sign-in links nobody's identity but the device's own; checked for the waiting identity first, which leaves the proof unspent. Every session of the account ends, in every service, and the sign-in stands ten minutes for a change, such as a lost password. - SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys (`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a SimpleLogin app and without it (`checkSimpleLoginMending`). - Members ask the register for both; `RemoteRegister` and the instance API carry them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:26:58 +02:00
);
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-loopback',
feat(base): mending a broken SimpleLogin link with the handle and the recovery phrase (ADR 0020, D7) Someone whose SimpleLogin identity is new to the register but who has an account ("I already have one") names their handle and proves the account's recovery phrase; the new identity takes the old one's place. - `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the phrase's key, the public keys and a nonce, for the account with that handle (whatever its case). Only an account with keys and a SimpleLogin identity is mended, a link it had, as nobody adds SimpleLogin signed out (D10); a handle nobody has, or any other account's, gets a decoy from `signInDecoyKey`, the same each time. At most 10 an hour per handle and 30 per network. - `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`, binding the attempt (`mendLinkDigest`, on both sides), so a proof relayed from another sign-in links nobody's identity but the device's own; checked for the waiting identity first, which leaves the proof unspent. Every session of the account ends, in every service, and the sign-in stands ten minutes for a change, such as a lost password. - SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys (`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a SimpleLogin app and without it (`checkSimpleLoginMending`). - Members ask the register for both; `RemoteRegister` and the instance API carry them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:26:58 +02:00
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-dora-new',
),
);
final decoy = await remote.beginSimpleLoginMend(
session,
attempt: start.attempt,
handle: 'nobody',
network: '203.0.113.0/24',
);
await expectLater(
remote.mendSimpleLogin(
session,
attempt: start.attempt,
proof: await device.provePhrase(
decoy,
digest: mendLinkDigest(start.attempt),
),
),
refused(PasswordRefusal.wrongCredentials),
);
final mended = await remote.mendSimpleLogin(
session,
attempt: start.attempt,
proof: await device.provePhrase(
await remote.beginSimpleLoginMend(
session,
attempt: start.attempt,
handle: 'dora',
network: '203.0.113.0/24',
),
digest: mendLinkDigest(start.attempt),
),
);
expect(mended.outcome, SimpleLoginOutcome.signedIn);
expect(mended.account, dora);
expect(mended.endedBefore, isNotNull);
expect(
(await OidcIdentity.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(dora),
))!.subject,
'sl-dora-new',
);
// Every member is told to end the account's sessions.
await sessionNoticesDelivered();
expect(members.sessionsTold.single.$2, dora);
},
);
test('refuses where the register has no SimpleLogin app', () async {
useSimpleLogin(sessionBuilder, null);
await expectLater(
remote.beginSimpleLogin(
session,
marker: 'todo-loopback',
network: '203.0.113.0/24',
),
refused(OidcRefusal.unknownProvider),
);
});
});
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',
purpose: EmailCodePurpose.signUp,
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.setPassword(
session,
person: annaOnTodo,
anonymousHere: true,
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
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),
proof: device.ownerProof(
await remote.beginPasswordChange(session, person: annaOnTodo),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(enrolment.authenticator),
),
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(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),
),
fix(auth)!: a session alone never gets a code checked The third reading found a stolen session guessing TOTP codes without end: nine wrong codes for new recovery codes, then removing an unconfirmed authenticator of its own, a success that cleared the session's count. As Proton has it, every signed-in change of how an account is secured now takes the owner's proof (OwnerProof): the password's SRP proof for a change's challenge and a TOTP or recovery code. Adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password and deleting take it; only the first authenticator is confirmed with its own code alone, and removing an unconfirmed one takes nothing. A proof given where none is taken is refused, and a confirmed authenticator's code is never checked for a session alone, so no success passes a proof unchecked. A session's proofs count for it alone, before they are checked, cleared only by one accepted; the tenth wrong in a row ends the session. They never count toward the account's waits nor wait for them, so whoever knows the password no longer holds a signed-in owner out of changing it (the second finding), and a new password forgets those waits. Beginning a change counts nothing against the account (the third finding): 30 an hour per session, holding back that session alone. Deleting takes a TOTP code wherever an authenticator is confirmed, a code by mail where no second factor works (the fourth finding), which SignInWays now tells (secondFactorWorks). Signed-in calls no longer take the address: nothing there mails. wrongExistingCode goes, a wrong existing code being a wrong proof. The app follows in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
refused(PasswordRefusal.wrongCredentials),
);
now = now.add(totpPeriod);
Future<OwnerProof> ownerProof(
PasswordProofPurpose purpose, {
List<int> digest = const [],
}) async => device.ownerProof(
fix(auth)!: a session alone never gets a code checked The third reading found a stolen session guessing TOTP codes without end: nine wrong codes for new recovery codes, then removing an unconfirmed authenticator of its own, a success that cleared the session's count. As Proton has it, every signed-in change of how an account is secured now takes the owner's proof (OwnerProof): the password's SRP proof for a change's challenge and a TOTP or recovery code. Adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password and deleting take it; only the first authenticator is confirmed with its own code alone, and removing an unconfirmed one takes nothing. A proof given where none is taken is refused, and a confirmed authenticator's code is never checked for a session alone, so no success passes a proof unchecked. A session's proofs count for it alone, before they are checked, cleared only by one accepted; the tenth wrong in a row ends the session. They never count toward the account's waits nor wait for them, so whoever knows the password no longer holds a signed-in owner out of changing it (the second finding), and a new password forgets those waits. Beginning a change counts nothing against the account (the third finding): 30 an hour per session, holding back that session alone. Deleting takes a TOTP code wherever an authenticator is confirmed, a code by mail where no second factor works (the fourth finding), which SignInWays now tells (secondFactorWorks). Signed-in calls no longer take the address: nothing there mails. wrongExistingCode goes, a wrong existing code being a wrong proof. The app follows in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
await remote.beginPasswordChange(session, person: annaOnTodo),
purpose: purpose,
digest: digest,
fix(auth)!: a session alone never gets a code checked The third reading found a stolen session guessing TOTP codes without end: nine wrong codes for new recovery codes, then removing an unconfirmed authenticator of its own, a success that cleared the session's count. As Proton has it, every signed-in change of how an account is secured now takes the owner's proof (OwnerProof): the password's SRP proof for a change's challenge and a TOTP or recovery code. Adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password and deleting take it; only the first authenticator is confirmed with its own code alone, and removing an unconfirmed one takes nothing. A proof given where none is taken is refused, and a confirmed authenticator's code is never checked for a session alone, so no success passes a proof unchecked. A session's proofs count for it alone, before they are checked, cleared only by one accepted; the tenth wrong in a row ends the session. They never count toward the account's waits nor wait for them, so whoever knows the password no longer holds a signed-in owner out of changing it (the second finding), and a new password forgets those waits. Beginning a change counts nothing against the account (the third finding): 30 an hour per session, holding back that session alone. Deleting takes a TOTP code wherever an authenticator is confirmed, a code by mail where no second factor works (the fourth finding), which SignInWays now tells (secondFactorWorks). Signed-in calls no longer take the address: nothing there mails. wrongExistingCode goes, a wrong existing code being a wrong proof. The app follows in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
totpCode: codeOf(enrolment),
);
expect(
await remote.confirmAuthenticator(
session,
person: annaOnTodo,
authenticator: spare.authenticator,
code: codeOf(spare),
proof: await ownerProof(
PasswordProofPurpose.addAuthenticator,
digest: authenticatorDigest(spare.authenticator),
),
),
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,
proof: await ownerProof(
PasswordProofPurpose.removeAuthenticator,
digest: authenticatorDigest(spare.authenticator),
),
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);
expect(ways.sessionEndedForProofsAt, isNull);
// A member's session ended for its wrong proofs: the owner's
// warning, kept here, read and dismissed through the member.
await remote.noteSessionEndedForProofs(session, annaOnTodo);
final warned = now;
expect(
(await remote.signInWaysOf(
session,
annaOnTodo,
)).sessionEndedForProofsAt,
warned,
);
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
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),
instanceUrl: testInstanceUrl,
address: 'anna@example.org',
language: 'en',
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());
expect(proof.sessionEndedForProofsAt, warned);
// An older session of the member's dismisses nothing (the full
// reading's L1); one made since does.
await expectLater(
remote.dismissSessionEndedWarning(
session,
annaOnTodo,
sessionMadeAt: warned,
),
refused(PasswordRefusal.signedInBefore),
);
await remote.dismissSessionEndedWarning(
session,
annaOnTodo,
sessionMadeAt: warned.add(const Duration(seconds: 1)),
);
expect(
(await remote.signInWaysOf(
session,
annaOnTodo,
)).sessionEndedForProofsAt,
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
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),
instanceUrl: testInstanceUrl,
address: 'anna@example.org',
language: 'en',
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,
proof: await ownerProof(PasswordProofPurpose.newRecoveryCodes),
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
),
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),
instanceUrl: testInstanceUrl,
address: 'anna@example.org',
language: 'en',
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.
await sessionNoticesDelivered();
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,
purpose: PasswordProofPurpose.authenticatorReset,
);
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 resetRequest = await remote.sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.authenticatorReset,
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 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'),
instanceUrl: testInstanceUrl,
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',
purpose: EmailCodePurpose.accountReset,
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.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',
purpose: EmailCodePurpose.accountReset,
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.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',
purpose: EmailCodePurpose.signUp,
);
await remote.setPassword(
session,
person: annaOnTodo,
anonymousHere: true,
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),
proof: device.ownerProof(
await remote.beginPasswordChange(
session,
person: annaOnTodo,
),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(enrolment.authenticator),
),
);
now = now.add(totpPeriod);
final challenge = await remote.beginPasswordChange(
session,
person: annaOnTodo,
);
// The old password proves it: the device takes the new one's
// x as it makes it.
final old = device.privateValue;
final newPassword = device.newPassword();
await remote.changePassword(
session,
person: annaOnTodo,
fix(auth)!: a session alone never gets a code checked The third reading found a stolen session guessing TOTP codes without end: nine wrong codes for new recovery codes, then removing an unconfirmed authenticator of its own, a success that cleared the session's count. As Proton has it, every signed-in change of how an account is secured now takes the owner's proof (OwnerProof): the password's SRP proof for a change's challenge and a TOTP or recovery code. Adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password and deleting take it; only the first authenticator is confirmed with its own code alone, and removing an unconfirmed one takes nothing. A proof given where none is taken is refused, and a confirmed authenticator's code is never checked for a session alone, so no success passes a proof unchecked. A session's proofs count for it alone, before they are checked, cleared only by one accepted; the tenth wrong in a row ends the session. They never count toward the account's waits nor wait for them, so whoever knows the password no longer holds a signed-in owner out of changing it (the second finding), and a new password forgets those waits. Beginning a change counts nothing against the account (the third finding): 30 an hour per session, holding back that session alone. Deleting takes a TOTP code wherever an authenticator is confirmed, a code by mail where no second factor works (the fourth finding), which SignInWays now tells (secondFactorWorks). Signed-in calls no longer take the address: nothing there mails. wrongExistingCode goes, a wrong existing code being a wrong proof. The app follows in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
proof: device.ownerProof(
challenge,
purpose: PasswordProofPurpose.newPassword,
digest: newPasswordDigest(newPassword),
privateValue: old,
fix(auth)!: a session alone never gets a code checked The third reading found a stolen session guessing TOTP codes without end: nine wrong codes for new recovery codes, then removing an unconfirmed authenticator of its own, a success that cleared the session's count. As Proton has it, every signed-in change of how an account is secured now takes the owner's proof (OwnerProof): the password's SRP proof for a change's challenge and a TOTP or recovery code. Adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password and deleting take it; only the first authenticator is confirmed with its own code alone, and removing an unconfirmed one takes nothing. A proof given where none is taken is refused, and a confirmed authenticator's code is never checked for a session alone, so no success passes a proof unchecked. A session's proofs count for it alone, before they are checked, cleared only by one accepted; the tenth wrong in a row ends the session. They never count toward the account's waits nor wait for them, so whoever knows the password no longer holds a signed-in owner out of changing it (the second finding), and a new password forgets those waits. Beginning a change counts nothing against the account (the third finding): 30 an hour per session, holding back that session alone. Deleting takes a TOTP code wherever an authenticator is confirmed, a code by mail where no second factor works (the fourth finding), which SignInWays now tells (secondFactorWorks). Signed-in calls no longer take the address: nothing there mails. wrongExistingCode goes, a wrong existing code being a wrong proof. The app follows in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
totpCode: codeOf(enrolment),
),
password: newPassword,
keepSession: annasPhone,
);
await sessionNoticesDelivered();
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 proofKey = ByteData.sublistView(device.recoveryProofKey);
await remote.setRecoveryKeys(
session,
person: annaOnTodo,
fix(auth)!: a session alone never gets a code checked The third reading found a stolen session guessing TOTP codes without end: nine wrong codes for new recovery codes, then removing an unconfirmed authenticator of its own, a success that cleared the session's count. As Proton has it, every signed-in change of how an account is secured now takes the owner's proof (OwnerProof): the password's SRP proof for a change's challenge and a TOTP or recovery code. Adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password and deleting take it; only the first authenticator is confirmed with its own code alone, and removing an unconfirmed one takes nothing. A proof given where none is taken is refused, and a confirmed authenticator's code is never checked for a session alone, so no success passes a proof unchecked. A session's proofs count for it alone, before they are checked, cleared only by one accepted; the tenth wrong in a row ends the session. They never count toward the account's waits nor wait for them, so whoever knows the password no longer holds a signed-in owner out of changing it (the second finding), and a new password forgets those waits. Beginning a change counts nothing against the account (the third finding): 30 an hour per session, holding back that session alone. Deleting takes a TOTP code wherever an authenticator is confirmed, a code by mail where no second factor works (the fourth finding), which SignInWays now tells (secondFactorWorks). Signed-in calls no longer take the address: nothing there mails. wrongExistingCode goes, a wrong existing code being a wrong proof. The app follows in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
proof: device.ownerProof(
challenge,
purpose: PasswordProofPurpose.newPhrase,
digest: newPhraseDigest(fresh, proofKey),
fix(auth)!: a session alone never gets a code checked The third reading found a stolen session guessing TOTP codes without end: nine wrong codes for new recovery codes, then removing an unconfirmed authenticator of its own, a success that cleared the session's count. As Proton has it, every signed-in change of how an account is secured now takes the owner's proof (OwnerProof): the password's SRP proof for a change's challenge and a TOTP or recovery code. Adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password and deleting take it; only the first authenticator is confirmed with its own code alone, and removing an unconfirmed one takes nothing. A proof given where none is taken is refused, and a confirmed authenticator's code is never checked for a session alone, so no success passes a proof unchecked. A session's proofs count for it alone, before they are checked, cleared only by one accepted; the tenth wrong in a row ends the session. They never count toward the account's waits nor wait for them, so whoever knows the password no longer holds a signed-in owner out of changing it (the second finding), and a new password forgets those waits. Beginning a change counts nothing against the account (the third finding): 30 an hour per session, holding back that session alone. Deleting takes a TOTP code wherever an authenticator is confirmed, a code by mail where no second factor works (the fourth finding), which SignInWays now tells (secondFactorWorks). Signed-in calls no longer take the address: nothing there mails. wrongExistingCode goes, a wrong existing code being a wrong proof. The app follows in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
totpCode: codeOf(enrolment),
),
recoveryKeys: fresh,
recoveryProofKey: proofKey,
);
}
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',
purpose: EmailCodePurpose.accountReset,
);
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',
purpose: EmailCodePurpose.accountReset,
);
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,
),
),
);
// The member's device is shown the reset, and its owner calls
// it off with their proof (the full reading's H1).
expect(
(await remote.signInWaysOf(session, annaOnTodo)).accountResetAt,
pending.takesEffectAt,
);
now = now.add(totpPeriod);
await remote.callOffResets(
session,
person: annaOnTodo,
proof: device.ownerProof(
await remote.beginPasswordChange(
session,
person: annaOnTodo,
),
purpose: PasswordProofPurpose.callOffResets,
totpCode: codeOf(enrolment),
),
);
expect(
(await remote.signInWaysOf(session, annaOnTodo)).accountResetAt,
isNull,
);
});
},
);
});
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([
EmailCodePurpose purpose = EmailCodePurpose.signUp,
]) => remote.sendSignInCode(
session,
address: 'carla@example.org',
language: 'en',
network: '203.0.113.7',
purpose: purpose,
);
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(EmailCodePurpose.finishSetup),
code: mailbox.codeFor('carla@example.org'),
instanceUrl: testInstanceUrl,
address: 'carla@example.org',
language: 'en',
);
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 OidcAttempt.db.deleteWhere(
session,
where: (t) => t.id.notEquals(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
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));
}