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

2548 lines
83 KiB
Dart
Raw Normal View History

import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
import 'package:halkyon_base_server/src/register/instance_api.dart';
import 'package:halkyon_base_server/src/register/register_protocol.dart';
import 'package:halkyon_base_server/src/register/remote_register.dart';
import 'package:halkyon_oidc_server/oidc.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 _learn = Uri.parse('http://learn.test:8082');
/// The instance's register, in memory: what a member asks it, and what it
/// answers.
final class _Register implements AccountRegister {
final accounts = <UuidValue>{};
final addresses = <String, UuidValue>{};
final gitHub = <String, UuidValue>{};
final profiles = <UuidValue, Profile>{};
final codes = <UuidValue, ({String address, String code})>{};
/// The calls asked, by name, in their order.
final asked = <String>[];
final networks = <String>[];
final deleted = <UuidValue>[];
/// What [knownAccounts] was asked about, call by call.
final askedAbout = <Set<UuidValue>>[];
bool offered = 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
/// Whether it offers password sign-in.
bool passwordOffered = true;
/// Whether it offers SimpleLogin, the sign-ins begun with it, the accounts
/// it was added to, and what the next sign-in proves.
bool simpleLoginIsOffered = true;
final simpleLoginsBegun = <({String marker, UuidValue? caller})>[];
final simpleLoginLinked = <UuidValue>{};
SimpleLoginProof? simpleLoginProof;
/// The handles checked (for someone new, or signed in) and what each
/// check says, the sign-ups of people new with SimpleLogin, and the proofs
/// keys were unlocked with, as members bound them.
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
final handlesChecked = <String>[];
ProfileRefusal? handleRefusal;
final simpleLoginSignUps = <({UuidValue attempt, String handle})>[];
final keysProofs = <KeysProof>[];
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
/// The accounts with a password, and those with an authenticator too.
final passwords = <UuidValue>{};
final authenticated = <UuidValue>{};
/// The people a password was set for with a GitHub sign-in of the
/// member's made just now.
final freshGitHubSignIns = <UuidValue>{};
/// When the session dismissing a warning was made, by person.
final dismissedMadeAt = <UuidValue, DateTime?>{};
/// Those [setPassword] was told are anonymous on the member.
final anonymousHere_ = <UuidValue>{};
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
/// What a password sign-in proves; null refuses it.
PasswordProof? proof;
/// Thrown by every call while set: the register refusing, or down.
Exception? failing;
/// The base URLs sign-ins were bound to, as members named them.
final instanceUrls = <String>[];
/// The accounts warned that a session was ended for its wrong proofs, and
/// those whose warning was dismissed.
final warned = <UuidValue>{};
final dismissed = <UuidValue>{};
/// Thrown by [noteSessionEndedForProofs] alone while set (as [failing]
/// throws from everything): the register down as a member tells it.
Exception? notingFails;
Future<T> _answer<T>(String call, T Function() answer) async {
asked.add(call);
if (failing case final error?) {
throw error;
}
return answer();
}
/// [person] as an account with the address [address].
void add(UuidValue person, {String? address}) {
accounts.add(person);
if (address != null) {
addresses[address] = person;
}
}
@override
Future<Profile?> profileOf(Session session, UuidValue person) =>
_answer('profileOf', () => profiles[person]);
@override
Future<Map<UuidValue, Profile>> profilesOf(
Session session,
Set<UuidValue> people,
) => _answer(
'profilesOf',
() => {
for (final person in people) person: ?profiles[person],
},
);
@override
Future<Profile?> profileByHandle(Session session, String handle) => _answer(
'profileByHandle',
() => profiles.values
.where((profile) => profile.handle.toLowerCase() == handle)
.firstOrNull,
);
@override
Future<UuidValue?> personWithHandle(Session session, String handle) =>
_answer(
'personWithHandle',
() => profiles.entries
.where((entry) => entry.value.handle.toLowerCase() == handle)
.firstOrNull
?.key,
);
@override
Future<Profile> saveProfile(
Session session,
UuidValue person, {
required String handle,
required String displayName,
required String bio,
required List<String> links,
String? network,
}) => _answer('saveProfile', () {
networks.add(network!);
return profiles[person] = Profile(
handle: handle,
address: '$handle@learn.test',
displayName: displayName,
bio: bio,
links: links,
pageUrl: 'https://learn.test/@$handle',
handleFixed: false,
);
});
@override
Future<ProfileRefusal?> checkHandle(
Session session,
UuidValue person,
String handle, {
required String network,
}) => _answer('checkHandle', () {
networks.add(network);
handlesChecked.add(handle);
return handleRefusal;
});
@override
Future<Profile> checkLinks(Session session, UuidValue person) =>
_answer('checkLinks', () => profiles[person]!);
@override
Future<Profile> setAvatar(Session session, UuidValue person, ByteData png) =>
_answer('setAvatar', () => profiles[person]!);
@override
Future<Profile> removeAvatar(Session session, UuidValue person) =>
_answer('removeAvatar', () => profiles[person]!);
@override
Future<void> removeProfile(Session session, UuidValue person) =>
_answer('removeProfile', () => profiles.remove(person));
@override
Future<void> fixHandle(Session session, UuidValue person) =>
_answer('fixHandle', () {});
@override
Future<bool> emailSignInOffered(Session session) =>
_answer('emailSignInOffered', () => offered);
@override
Future<UuidValue> sendSignInCode(
Session session, {
required String address,
required String language,
required EmailCodePurpose purpose,
required String network,
}) => _answer('sendSignInCode', () {
networks.add(network);
final request = const Uuid().v7obj();
codes[request] = (address: address, code: 'K7QX-M2PD');
return request;
});
@override
Future<void> linkGitHub(
Session session, {
required UuidValue person,
required String gitHubUserId,
}) => _answer('linkGitHub', () {
accounts.add(person);
gitHub[gitHubUserId] = person;
});
@override
Future<UuidValue?> accountWithGitHub(Session session, String gitHubUserId) =>
_answer('accountWithGitHub', () => gitHub[gitHubUserId]);
@override
Future<SignInWays> signInWaysOf(Session session, UuidValue person) => _answer(
'signInWaysOf',
() => SignInWays(
gitHub: gitHub.containsValue(person),
email: addresses.containsValue(person),
simpleLogin: simpleLoginLinked.contains(person),
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
password: passwords.contains(person),
authenticator: authenticated.contains(person),
passwordOnly:
passwords.contains(person) && authenticated.contains(person),
),
);
@override
Future<void> noteSessionEndedForProofs(
Session session,
UuidValue person,
) async {
// Not through [_answer]: a session's tenth wrong proof comes from the
// register refusing it, while [failing] refuses everything.
asked.add('noteSessionEndedForProofs');
if (notingFails case final error?) {
throw error;
}
warned.add(person);
}
@override
Future<void> dismissSessionEndedWarning(
Session session,
UuidValue person, {
required DateTime? sessionMadeAt,
}) => _answer('dismissSessionEndedWarning', () {
dismissed.add(person);
dismissedMadeAt[person] = sessionMadeAt;
});
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<bool> passwordSignInOffered(Session session) =>
_answer('passwordSignInOffered', () => passwordOffered);
@override
Future<bool> simpleLoginOffered(Session session) =>
_answer('simpleLoginOffered', () => simpleLoginIsOffered);
@override
Future<OidcStart> beginSimpleLogin(
Session session, {
required String marker,
UuidValue? caller,
required String network,
}) => _answer('beginSimpleLogin', () {
networks.add(network);
simpleLoginsBegun.add((marker: marker, caller: caller));
return OidcStart(
attempt: const Uuid().v7obj(),
authorizationUrl: Uri.parse(
'https://app.simplelogin.io/oauth2/authorize',
),
state: '$marker.state',
);
});
@override
Future<SimpleLoginProof> completeSimpleLogin(
Session session, {
required UuidValue attempt,
required String code,
UuidValue? caller,
}) => _answer('completeSimpleLogin', () {
final proof = simpleLoginProof!;
if (proof.outcome == SimpleLoginOutcome.linked) {
simpleLoginLinked.add(caller!);
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
return proof.copyWith(account: caller);
}
return proof;
});
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
@override
Future<ProfileRefusal?> checkSimpleLoginHandle(
Session session, {
required UuidValue attempt,
required String handle,
required String network,
}) => _answer('checkSimpleLoginHandle', () {
networks.add(network);
handlesChecked.add(handle);
return handleRefusal;
});
@override
Future<UuidValue> signUpWithSimpleLogin(
Session session, {
required UuidValue attempt,
required String handle,
required KeysSetup setup,
required String network,
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
}) => _answer('signUpWithSimpleLogin', () {
networks.add(network);
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
final account = const Uuid().v7obj();
add(account);
simpleLoginSignUps.add((attempt: attempt, handle: handle));
return account;
});
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
/// The first keys set, by person, with the sign-in and handle they
/// came with; the changes of keys asked, by name, with the proof (as the
/// member bound it) and the session kept.
final firstKeys = <UuidValue, ({UuidValue attempt, String? handle})>{};
final keysChanges =
<({String change, KeysOwnerProof proof, UuidValue? keepSession})>[];
@override
Future<void> setFirstKeys(
Session session, {
required UuidValue person,
required UuidValue attempt,
required KeysSetup setup,
required String network,
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
String? handle,
}) => _answer('setFirstKeys', () {
networks.add(network);
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
firstKeys[person] = (attempt: attempt, handle: handle);
});
@override
Future<KeyProofChallenge> beginPhraseKeyProof(
Session session,
UuidValue person,
) => _answer(
'beginPhraseKeyProof',
() => KeyProofChallenge(
challenge: const Uuid().v7obj(),
nonce: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
recoveryKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)),
),
);
@override
Future<void> changeKeysPassword(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required KeysPassword password,
UuidValue? keepSession,
}) => _answer('changeKeysPassword', () {
keysChanges.add((
change: 'password',
proof: proof,
keepSession: keepSession,
));
});
@override
Future<void> resetKeysPassword(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required KeysPassword password,
UuidValue? keepSession,
}) => _answer('resetKeysPassword', () {
keysChanges.add((change: 'reset', proof: proof, keepSession: keepSession));
});
@override
Future<void> setKeysRecovery(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required WrappedKeys recoveryKeys,
required ByteData recoveryProofKey,
}) => _answer('setKeysRecovery', () {
keysChanges.add((change: 'phrase', proof: proof, keepSession: null));
});
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
/// The handles mends began for, the proofs mends came with (as the
/// member bound them), and what the next mend proves.
final mendsBegun = <String>[];
final mendProofs = <KeysProof>[];
SimpleLoginProof? mended;
@override
Future<KeyProofChallenge> beginSimpleLoginMend(
Session session, {
required UuidValue attempt,
required String handle,
required String network,
}) => _answer('beginSimpleLoginMend', () {
networks.add(network);
mendsBegun.add(handle);
return KeyProofChallenge(
challenge: const Uuid().v7obj(),
nonce: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
recoveryKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)),
);
});
@override
Future<SimpleLoginProof> mendSimpleLogin(
Session session, {
required UuidValue attempt,
required KeysProof proof,
}) {
mendProofs.add(proof);
return _answer('mendSimpleLogin', () => mended!);
}
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
@override
Future<KeysChallenge> beginPasswordKeyProof(
Session session,
UuidValue person,
) => _answer(
'beginPasswordKeyProof',
() => KeysChallenge(
challenge: const Uuid().v7obj(),
nonce: ByteData(32),
salt: ByteData(16),
costs: newPasswordCosts,
),
);
@override
Future<DeviceKeys> unlockKeys(
Session session,
UuidValue person,
KeysProof proof,
) {
keysProofs.add(proof);
return _answer(
'unlockKeys',
() => DeviceKeys(
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
),
);
}
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> setPassword(
Session session, {
required UuidValue person,
required PasswordSetup setup,
UuidValue? request,
String? code,
bool freshGitHubSignIn = false,
bool anonymousHere = false,
UuidValue? keepSession,
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
}) => _answer('setPassword', () {
add(person);
passwords.add(person);
if (freshGitHubSignIn) {
freshGitHubSignIns.add(person);
}
if (anonymousHere) {
anonymousHere_.add(person);
}
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<PasswordChallenge> beginPasswordSignIn(
Session session, {
required String address,
required String network,
}) => _answer('beginPasswordSignIn', () {
networks.add(network);
return PasswordChallenge(
challenge: const Uuid().v7obj(),
salt: ByteData(16),
costs: newPasswordCosts,
serverValue: ByteData(384),
);
});
PasswordProof _proven() =>
proof ??
(throw PasswordRefusedException(
reason: PasswordRefusal.wrongCredentials,
));
@override
Future<PasswordProof> completePasswordSignIn(
Session session, {
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
UuidValue? request,
String? code,
required String instanceUrl,
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
String? address,
String? language,
}) {
instanceUrls.add(instanceUrl);
return _answer('completePasswordSignIn', _proven);
}
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<UuidValue> signUpWithCode(
Session session, {
required UuidValue request,
required String code,
required PasswordSetup setup,
}) => _answer('signUpWithCode', () {
final account = const Uuid().v7obj();
add(account);
passwords.add(account);
return account;
});
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<AuthenticatorEnrolment> enrolAuthenticator(
Session session, {
required UuidValue person,
required String name,
required String label,
}) => _answer(
'enrolAuthenticator',
() => AuthenticatorEnrolment(
authenticator: const Uuid().v7obj(),
uri: 'otpauth://totp/learn.test:$label',
),
);
@override
Future<List<String>> confirmAuthenticator(
Session session, {
required UuidValue person,
required UuidValue authenticator,
required String code,
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
OwnerProof? proof,
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
}) => _answer('confirmAuthenticator', () {
authenticated.add(person);
return const ['K7QX-M2PD'];
});
@override
Future<List<Authenticator>> authenticatorsOf(
Session session,
UuidValue person,
) => _answer('authenticatorsOf', () => const <Authenticator>[]);
@override
Future<void> removeAuthenticator(
Session session, {
required UuidValue person,
required UuidValue authenticator,
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
OwnerProof? proof,
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
}) => _answer('removeAuthenticator', () {});
@override
Future<List<String>> newRecoveryCodes(
Session session, {
required UuidValue person,
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
required OwnerProof proof,
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
}) => _answer('newRecoveryCodes', () => const ['K7QX-M2PD']);
@override
Future<ResetPending> resetAuthenticator(
Session session, {
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
required String address,
required String language,
UuidValue? request,
String? code,
UuidValue? keyChallenge,
ByteData? signature,
required String instanceUrl,
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
}) => _answer(
'resetAuthenticator',
() => ResetPending(takesEffectAt: DateTime.utc(2026, 10, 23)),
);
@override
Future<KeyProofChallenge> beginKeyProof(
Session session, {
required String address,
required String network,
}) => _answer('beginKeyProof', () {
networks.add(network);
return KeyProofChallenge(
challenge: const Uuid().v7obj(),
nonce: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
recoveryKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)),
);
});
/// The moment a reset ended the account's sessions at the register.
DateTime endedBefore = DateTime.utc(2026, 10, 20, 12);
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<AccountSessionsEnded> resetPasswordWithPhrase(
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
Session session, {
required UuidValue challenge,
required ByteData signature,
required NewPassword password,
String? totpCode,
String? recoveryCode,
required String instanceUrl,
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
String? address,
String? language,
}) => _answer(
'resetPasswordWithPhrase',
() => (account: _proven().account, endedBefore: endedBefore),
);
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<ResetPending> startAccountReset(
Session session, {
required String address,
required String language,
required UuidValue request,
required String code,
}) => _answer(
'startAccountReset',
() => ResetPending(takesEffectAt: DateTime.utc(2026, 10, 23)),
);
@override
Future<AccountSessionsEnded> completeAccountReset(
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
Session session, {
required String address,
required UuidValue request,
required String code,
required PasswordSetup setup,
}) => _answer(
'completeAccountReset',
() => (account: _proven().account, endedBefore: endedBefore),
);
/// The sessions kept by the password changes asked, by person.
final changed = <UuidValue, UuidValue?>{};
@override
Future<PasswordChallenge> beginPasswordChange(
Session session, {
required UuidValue person,
}) => _answer(
'beginPasswordChange',
() => PasswordChallenge(
challenge: const Uuid().v7obj(),
salt: ByteData(16),
costs: newPasswordCosts,
serverValue: ByteData(384),
),
);
@override
Future<void> changePassword(
Session session, {
required UuidValue person,
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
required OwnerProof proof,
required NewPassword password,
UuidValue? keepSession,
}) => _answer('changePassword', () {
changed[person] = keepSession;
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
changedWith.add(proof);
});
@override
Future<void> callOffResets(
Session session, {
required UuidValue person,
required OwnerProof proof,
}) => _answer('callOffResets', () {});
@override
Future<void> setRecoveryKeys(
Session session, {
required UuidValue person,
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
required OwnerProof proof,
required WrappedKeys recoveryKeys,
required ByteData recoveryProofKey,
}) => _answer('setRecoveryKeys', () {});
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> deleteAccount(
Session session,
UuidValue person, {
DeletionProof? proof,
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
KeysOwnerProof? keysProof,
}) => _answer('deleteAccount', () {
deleted.add(person);
deletedWith.add(proof);
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
deletedWithKeys.add(keysProof);
accounts.remove(person);
});
/// The proofs deletions came with, as the member bound them.
final deletedWith = <DeletionProof?>[];
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 deletedWithKeys = <KeysOwnerProof?>[];
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
final changedWith = <OwnerProof>[];
@override
Future<Set<UuidValue>> knownAccounts(
Session session,
Set<UuidValue> people,
) => _answer('knownAccounts', () {
askedAbout.add(people);
return people.intersection(accounts);
});
}
void main() {
withServerpod(
'A member of an instance',
// Sessions issued and revoked, and deletions in transactions of their
// own: these tests delete what they make instead.
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
late Serverpod pod;
late _Register register;
/// Anonymous here, signed in on her phone.
late UuidValue anna;
late UuidValue annasPhone;
/// An account of the register, identified here, signed in on his
/// laptop.
late UuidValue ben;
late UuidValue bensLaptop;
/// Whose data the host deleted.
late List<UuidValue> hostDeleted;
setUp(() async {
hostDeleted = [];
useBase(
sessionBuilder,
accounts: MemberOfRegister(_learn),
deletePersonData: [
(session, person, transaction) async {
hostDeleted.add(person);
return null;
},
],
);
session = sessionBuilder.build();
pod = session.serverpod;
await _deleteAll(session);
useInstanceSecret(pod, _secret);
register = _Register();
AccountRegister.use(pod, register);
anna = await newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
ben = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
register.add(ben, address: 'ben@example.org');
bensLaptop = await signInNewDevice(session, ben, scopes: {identified});
});
tearDown(() async {
AccountRegister.use(pod, null);
useInstanceSecret(pod, null);
await _deleteAll(sessionBuilder.build());
});
TestSessionBuilder asAnna() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{},
authId: annasPhone.uuid,
),
);
TestSessionBuilder asBen() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{identified},
authId: bensLaptop.uuid,
),
);
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
Future<Set<String>> scopesOf(UuidValue person) async =>
(await AuthUser.db.findById(session, person))!.scopeNames;
group('signs in by asking the register', () {
test('which mails the code, its device network passed on', () async {
await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'de',
purpose: EmailCodePurpose.signUp,
);
expect(register.asked, ['sendSignInCode']);
expect(register.codes.values.single.address, 'anna@example.org');
expect(register.networks, hasLength(1));
});
test('and refuses as the register refuses', () async {
register.failing = EmailSignInRefusedException(
reason: EmailSignInRefusal.tooManyCodes,
);
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'de',
purpose: EmailCodePurpose.signUp,
),
throwsA(
isA<EmailSignInRefusedException>().having(
(error) => error.reason,
'reason',
EmailSignInRefusal.tooManyCodes,
),
),
);
});
test('an account signing in again is the same person here', () async {
await issueSession(session, ben, method: 'test');
await issueSession(session, ben, method: 'test');
expect(
await AuthUser.db.count(session, where: (t) => t.id.equals(ben)),
1,
);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
3,
);
});
test('an account the register proved is identified here even where '
'linking its way in was cut off', () async {
final success = await issueSession(session, anna, method: 'test');
expect(success.authUserId, anna);
expect(await scopesOf(anna), contains(identified.name));
expect(
(await ServerSideSession.db.findById(
session,
annasPhone,
))!.scopeNames,
contains(identified.name),
);
});
test('email sign-in is offered as the register offers it', () async {
expect(
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
isTrue,
);
register.offered = false;
expect(
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
isFalse,
);
});
test('the basics still answer while the register is out of reach, '
'without email sign-in', () async {
register.failing = RegisterUnavailableException('test');
final basics = await endpoints.instance.basics(notSignedIn());
expect(basics.emailSignIn, isFalse);
expect(basics.app, testApp);
});
test('the ways in are those the register knows', () async {
register.gitHub['1001'] = ben;
final ways = await endpoints.account.signInWays(asBen());
expect(ways.toJson(), SignInWays(gitHub: true, email: true).toJson());
});
});
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, by asking the register (ADR 0017)', () {
test('sign the account the register proved in here, made a person '
'here the first time, the device network passed on', () async {
final carla = const Uuid().v7obj();
register.proof = PasswordProof(
account: carla,
serverProof: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
needsAuthenticator: false,
);
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'carla@example.org',
);
final signedIn = await endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '123456',
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(register.asked, [
'beginPasswordSignIn',
'completePasswordSignIn',
]);
expect(register.networks, hasLength(1));
// Bound to the base URL the device addressed: this member's.
expect(register.instanceUrls, [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(signedIn.authUserId, carla);
expect(await scopesOf(carla), {identified.name});
expect(
(await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(carla),
)).single.method,
passwordMethod,
);
});
test('carrying out a reset of the authenticators, end the sessions '
'here made before the register ended them, the new one staying '
'(the fourth reading)', () async {
final endedBefore = DateTime.now().toUtc();
register.proof = PasswordProof(
account: anna,
serverProof: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
needsAuthenticator: true,
sessionsEndedBefore: endedBefore,
);
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'anna@example.org',
);
final signedIn = await endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: ByteData(384),
clientProof: ByteData(32),
instanceUrl: testInstanceUrl,
address: 'anna@example.org',
language: 'en',
);
expect(signedIn.authUserId, anna);
expect(
await ServerSideSession.db.findById(session, annasPhone),
isNull,
);
final left = await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(anna),
);
expect(left, hasLength(1));
expect(left.single.createdAt.isBefore(endedBefore), isFalse);
});
test("count a proof whose answer the register couldn't give, as it "
'may have checked it (the fourth reading)', () async {
register.failing = RegisterUnavailableException('test');
await expectLater(
endpoints.authenticator.newRecoveryCodes(
asAnna(),
proof: OwnerProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
instanceUrl: testInstanceUrl,
totpCode: '123456',
),
),
throwsA(isA<RegisterUnavailableException>()),
);
expect(
(await SessionCodeFailures.db.findFirstRow(
session,
where: (t) => t.sessionId.equals(annasPhone),
))?.failures,
1,
);
});
test('sign up at the register, the new account signed in here, '
'identified', () async {
final device = await PasswordDevice.make();
final signedUp = await endpoints.passwordSignIn.signUp(
notSignedIn(),
request: const Uuid().v7obj(),
code: 'K7QX-M2PD',
setup: device.setup(),
);
expect(register.asked, ['signUpWithCode']);
expect(register.passwords, {signedUp.authUserId});
expect(await scopesOf(signedUp.authUserId), {identified.name});
expect(
(await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(signedUp.authUserId),
)).single.method,
passwordMethod,
);
});
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
test('refuse as the register refuses, signing nobody in', () async {
final people = await AuthUser.db.count(session);
await expectLater(
endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
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
),
throwsA(
isA<PasswordRefusedException>().having(
(error) => error.reason,
'reason',
PasswordRefusal.wrongCredentials,
),
),
);
expect(await AuthUser.db.count(session), people);
});
test('set one at the register, the person becoming identified '
'here', () async {
final device = await PasswordDevice.make();
await endpoints.password.set(
asAnna(),
setup: device.setup(),
request: const Uuid().v7obj(),
code: 'K7QX-M2PD',
);
expect(register.passwords, {anna});
expect(register.freshGitHubSignIns, isEmpty);
// Anna was anonymous here, which the register can't know (the
// full reading's M1).
expect(register.anonymousHere_, {anna});
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 scopesOf(anna), contains(identified.name));
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
});
test(
'tell the register whether the session asking was made by a '
'GitHub sign-in of the last ten minutes (the fourth reading)',
() async {
final device = await PasswordDevice.make();
Future<void> setFrom(UuidValue device_) => endpoints.password.set(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{},
authId: device_.uuid,
),
),
setup: device.setup(),
request: const Uuid().v7obj(),
code: 'K7QX-M2PD',
);
final old = await withClock(
Clock.fixed(DateTime.now().subtract(const Duration(minutes: 11))),
() => signInNewDevice(session, anna, method: gitHubMethod),
);
await setFrom(old);
expect(register.freshGitHubSignIns, isEmpty);
await setFrom(annasPhone);
expect(register.freshGitHubSignIns, isEmpty);
await setFrom(
await signInNewDevice(session, anna, method: gitHubMethod),
);
expect(register.freshGitHubSignIns, {anna});
},
);
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
test('are offered as the register offers them, and not while it is '
'out of reach', () async {
expect(
(await endpoints.instance.basics(notSignedIn())).passwordSignIn,
isTrue,
);
register.passwordOffered = false;
expect(
(await endpoints.instance.basics(notSignedIn())).passwordSignIn,
isFalse,
);
register
..passwordOffered = true
..failing = RegisterUnavailableException('test');
expect(
(await endpoints.instance.basics(notSignedIn())).passwordSignIn,
isFalse,
);
});
test('keep GitHub from signing into an account that has one, as '
'the register knows it', () async {
register.gitHub['1001'] = ben;
expect(await gitHubAccountToSignIn(session, '1001'), ben);
register
..passwords.add(ben)
..authenticated.add(ben);
await expectLater(
gitHubAccountToSignIn(session, '1001'),
throwsA(
isA<GitHubSignInRefusedException>().having(
(error) => error.reason,
'reason',
GitHubSignInRefusal.usePassword,
),
),
);
});
});
group('SimpleLogin, by asking the register (ADR 0020)', () {
test('begins for this app and way back, signed out, the device '
'network passed on', () async {
final start = await endpoints.simpleLogin.begin(
notSignedIn(),
back: SimpleLoginReturn.scheme,
);
expect(register.asked, ['beginSimpleLogin']);
expect(register.simpleLoginsBegun.single, (
marker: '$testApp-scheme',
caller: null,
));
expect(register.networks, hasLength(1));
expect(start.state, startsWith('$testApp-scheme.'));
});
test('begins signed in for the caller', () async {
await endpoints.simpleLogin.begin(
asAnna(),
back: SimpleLoginReturn.loopback,
);
expect(register.simpleLoginsBegun.single, (
marker: '$testApp-loopback',
caller: anna,
));
});
test('signs the account the register found in here, made a person '
'here the first time', () async {
final carla = const Uuid().v7obj();
register.simpleLoginProof = SimpleLoginProof(
outcome: SimpleLoginOutcome.signedIn,
account: carla,
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
hasKeys: true,
handle: 'Carla',
);
final completed = await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: const Uuid().v7obj(),
code: 'code',
);
expect(register.asked, ['completeSimpleLogin']);
expect(completed.outcome, SimpleLoginOutcome.signedIn);
expect(completed.session!.authUserId, carla);
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((completed.hasKeys, completed.handle), (true, 'Carla'));
expect(await scopesOf(carla), {identified.name});
expect(
(await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(carla),
)).single.method,
simpleLoginMethod,
);
});
test('makes the caller identified here once the register added '
'SimpleLogin to their account', () async {
register.simpleLoginProof = SimpleLoginProof(
outcome: SimpleLoginOutcome.linked,
);
final completed = await endpoints.simpleLogin.complete(
asAnna(),
attempt: const Uuid().v7obj(),
code: 'code',
);
expect(completed.outcome, SimpleLoginOutcome.linked);
expect(completed.session, isNull);
expect(register.simpleLoginLinked, {anna});
expect(await scopesOf(anna), contains(identified.name));
expect(
await AnonymousAccount.db.find(
session,
where: (t) => t.authUserId.equals(anna),
),
isEmpty,
);
expect(
(await endpoints.account.signInWays(asAnna())).simpleLogin,
isTrue,
);
});
test('passes on the handle suggested to someone new, with no session '
'and no person here', () async {
final people = await AuthUser.db.count(session);
register.simpleLoginProof = SimpleLoginProof(
outcome: SimpleLoginOutcome.newcomer,
suggestedHandle: 'carla_mueller',
);
final completed = await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: const Uuid().v7obj(),
code: 'code',
);
expect(completed.outcome, SimpleLoginOutcome.newcomer);
expect(completed.session, isNull);
expect(completed.suggestedHandle, 'carla_mueller');
expect(await AuthUser.db.count(session), people);
});
test('refuses as the register refuses', () async {
register.failing = OidcRefusedException(
reason: OidcRefusal.takenByAnotherPerson,
);
await expectLater(
endpoints.simpleLogin.complete(
asAnna(),
attempt: const Uuid().v7obj(),
code: 'code',
),
throwsA(
isA<OidcRefusedException>().having(
(error) => error.reason,
'reason',
OidcRefusal.takenByAnotherPerson,
),
),
);
expect(await scopesOf(anna), isNot(contains(identified.name)));
});
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('checks a handle for someone new at the register, the device '
'network passed on', () async {
final attempt = const Uuid().v7obj();
register.handleRefusal = ProfileRefusal.handleTaken;
expect(
await endpoints.simpleLogin.checkHandle(
notSignedIn(),
attempt: attempt,
handle: 'carla',
),
ProfileRefusal.handleTaken,
);
expect(register.asked, ['checkSimpleLoginHandle']);
expect(register.handlesChecked, ['carla']);
expect(register.networks, hasLength(1));
});
test('signs up someone new at the register, a person here with a '
'session of this server', () async {
final attempt = const Uuid().v7obj();
final signedUp = await endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: attempt,
handle: 'carla',
setup: (await KeysDevice.make()).setup(),
);
final carla = signedUp.authUserId;
expect(register.asked, ['signUpWithSimpleLogin']);
expect(register.networks, hasLength(1));
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(register.simpleLoginSignUps.single, (
attempt: attempt,
handle: 'carla',
));
expect(register.accounts, contains(carla));
expect(await scopesOf(carla), {identified.name});
expect(
(await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(carla),
)).single.method,
simpleLoginMethod,
);
});
test('signs up nobody for a signed-in caller, nor as the register '
'refuses, making no person here', () async {
final people = await AuthUser.db.count(session);
final setup = (await KeysDevice.make()).setup();
await expectLater(
endpoints.simpleLogin.signUp(
asAnna(),
attempt: const Uuid().v7obj(),
handle: 'carla',
setup: setup,
),
throwsA(
isA<OidcRefusedException>().having(
(error) => error.reason,
'reason',
OidcRefusal.wrongCaller,
),
),
);
expect(register.asked, isEmpty);
register.failing = ProfileRefusedException(
reason: ProfileRefusal.handleTaken,
);
await expectLater(
endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: const Uuid().v7obj(),
handle: 'carla',
setup: setup,
),
throwsA(
isA<ProfileRefusedException>().having(
(error) => error.reason,
'reason',
ProfileRefusal.handleTaken,
),
),
);
expect(await AuthUser.db.count(session), people);
});
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('begins mending a link at the register, the device network '
'passed on, and never for a signed-in caller', () async {
await endpoints.simpleLogin.beginMend(
notSignedIn(),
attempt: const Uuid().v7obj(),
handle: 'Ben',
);
expect(register.asked, ['beginSimpleLoginMend']);
expect(register.mendsBegun, ['Ben']);
expect(register.networks, hasLength(1));
await expectLater(
endpoints.simpleLogin.beginMend(
asAnna(),
attempt: const Uuid().v7obj(),
handle: 'Ben',
),
throwsA(
isA<OidcRefusedException>().having(
(error) => error.reason,
'reason',
OidcRefusal.wrongCaller,
),
),
);
expect(register.asked, hasLength(1));
});
test('mends a link at the register, the proof bound to the base URL '
"the device addressed here, the account's sessions here made "
'before the register ended them ending too', () async {
register.mended = SimpleLoginProof(
outcome: SimpleLoginOutcome.signedIn,
account: ben,
hasKeys: true,
handle: 'Ben',
endedBefore: DateTime.now().toUtc(),
);
final completed = await endpoints.simpleLogin.mend(
notSignedIn(),
attempt: const Uuid().v7obj(),
proof: KeysProof(
challenge: const Uuid().v7obj(),
signature: ByteData(64),
instanceUrl: 'HTTP://LOCALHOST:0',
),
);
expect(register.mendProofs.single.instanceUrl, testInstanceUrl);
expect(completed.session!.authUserId, ben);
expect((completed.hasKeys, completed.handle), (true, 'Ben'));
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNull,
);
expect(
(await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben),
)).single.method,
simpleLoginMethod,
);
});
test('is offered as the register offers it, and not while it is out '
'of reach', () async {
expect(
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
isTrue,
);
register.simpleLoginIsOffered = false;
expect(
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
isFalse,
);
register
..simpleLoginIsOffered = true
..failing = RegisterUnavailableException('test');
expect(
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
isFalse,
);
});
});
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
group('keys without a password that signs in, by asking the register '
'(ADR 0020, D19)', () {
KeysProof wrongProof() => KeysProof(
challenge: const Uuid().v7obj(),
signature: ByteData(64),
instanceUrl: testInstanceUrl,
);
test('are begun and unlocked at the register, the proof bound to '
'the base URL the device addressed here', () async {
final challenge = await endpoints.keys.begin(asBen());
final keys = await endpoints.keys.unlock(
asBen(),
KeysProof(
challenge: challenge.challenge,
signature: ByteData(64),
instanceUrl: testInstanceUrl,
),
);
expect(register.asked, ['beginPasswordKeyProof', 'unlockKeys']);
expect(keys.signingPublicKey.lengthInBytes, 32);
expect(register.keysProofs.single.challenge, challenge.challenge);
expect(register.keysProofs.single.instanceUrl, testInstanceUrl);
});
test('refuse a proof for another instance without asking the '
'register', () async {
await expectLater(
endpoints.keys.unlock(
asBen(),
wrongProof().copyWith(instanceUrl: 'https://evil.example/'),
),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
PasswordRefusal.otherInstance,
),
),
);
expect(register.asked, isEmpty);
});
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
test('are set first at the register with the sign-in and handle, '
'for the caller', () async {
final attempt = const Uuid().v7obj();
await endpoints.keys.set(
asBen(),
attempt: attempt,
setup: (await KeysDevice.make()).setup(),
handle: 'ben',
);
expect(register.asked, ['setFirstKeys']);
expect(register.networks, hasLength(1));
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
expect(register.firstKeys, {ben: (attempt: attempt, handle: 'ben')});
});
test('are changed at the register, each proof bound to the base URL '
'the device addressed here, this session kept where every other '
'ends', () async {
final proof = KeysOwnerProof(
challenge: const Uuid().v7obj(),
signature: ByteData(64),
instanceUrl: testInstanceUrl,
attempt: const Uuid().v7obj(),
);
final password = KeysPassword(
salt: ByteData(16),
costs: newPasswordCosts,
passwordProofKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
);
await endpoints.keys.changePassword(
asBen(),
proof: proof,
password: password,
);
await endpoints.keys.beginPhrase(asBen());
await endpoints.keys.resetPassword(
asBen(),
proof: proof,
password: password,
);
await endpoints.keys.setRecoveryKeys(
asBen(),
proof: proof,
recoveryKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
recoveryProofKey: ByteData(32),
);
expect(register.asked, [
'changeKeysPassword',
'beginPhraseKeyProof',
'resetKeysPassword',
'setKeysRecovery',
]);
expect(
[
for (final change in register.keysChanges)
(change.change, change.keepSession),
],
[
('password', bensLaptop),
('reset', bensLaptop),
('phrase', null),
],
);
expect(
register.keysChanges.map((change) => change.proof.instanceUrl),
everyElement(testInstanceUrl),
);
await expectLater(
endpoints.keys.changePassword(
asBen(),
proof: proof.copyWith(instanceUrl: 'https://evil.example/'),
password: password,
),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
PasswordRefusal.otherInstance,
),
),
);
expect(register.asked, hasLength(4));
});
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("count the session's own wrong proofs, which the register "
'refuses: the tenth in a row ends the session here', () async {
register.failing = PasswordRefusedException(
reason: PasswordRefusal.wrongCredentials,
);
for (var i = 0; i < 10; i++) {
await expectLater(
endpoints.keys.unlock(asBen(), wrongProof()),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
i < 9
? PasswordRefusal.wrongCredentials
: PasswordRefusal.sessionEnded,
),
),
);
}
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNull,
);
expect(register.warned, {ben});
});
});
group('profiles', () {
test('are saved and found at the register', () async {
final saved = await endpoints.profile.save(
asBen(),
'Ben',
'Ben',
'',
const [],
);
expect(register.profiles[ben]!.toJson(), saved.toJson());
expect(register.networks, hasLength(1));
expect(
(await endpoints.profiles.byHandle(asAnna(), 'ben')).toJson(),
saved.toJson(),
);
expect((await profileOf(session, ben))!.toJson(), saved.toJson());
expect(await PublicProfile.db.count(session), 0);
});
test('have their handles checked at the register, the device '
'network passed on to be counted there', () async {
register.handleRefusal = ProfileRefusal.tooManyHandleChecks;
expect(
await endpoints.profile.checkHandle(asBen(), 'Ben'),
ProfileRefusal.tooManyHandleChecks,
);
expect(register.asked, ['checkHandle']);
expect(register.handlesChecked, ['Ben']);
expect(register.networks, hasLength(1));
});
test("have their page at the register's address, where this "
"server's sends its visitors", () async {
await endpoints.profile.save(asBen(), 'Ben', 'Ben', '', const []);
final page = await profilePage(
session,
'@ben',
host: 'todo.test',
);
expect(page.status, 308);
expect(page.location, 'https://learn.test/@Ben');
});
});
group('deleting the account', () {
test('of an identified person deletes it at the register, which '
'tells every member, and here at once', () async {
await endpoints.account.delete(asBen());
expect(register.deleted, [ben]);
expect(await AuthUser.db.findById(session, ben), isNull);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
0,
);
expect(hostDeleted, [ben]);
expect(await AuthUser.db.findById(session, anna), isNotNull);
});
test("passes the password's proof on to the register, bound to "
'the base URL the device addressed here', () async {
await endpoints.account.delete(
asBen(),
proof: DeletionProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '123456',
instanceUrl: 'HTTP://LOCALHOST:0',
),
);
expect(register.deleted, [ben]);
expect(register.deletedWith.single?.instanceUrl, testInstanceUrl);
expect(register.deletedWith.single?.totpCode, '123456');
});
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
test('passes the proof of keys without SRP on to the register, with '
'its SimpleLogin sign-in, bound alike', () async {
final attempt = const Uuid().v7obj();
await endpoints.account.delete(
asBen(),
keysProof: KeysOwnerProof(
challenge: const Uuid().v7obj(),
signature: ByteData(64),
instanceUrl: 'HTTP://LOCALHOST:0',
attempt: attempt,
),
);
expect(register.deleted, [ben]);
expect(register.deletedWith.single, isNull);
expect(
register.deletedWithKeys.single?.instanceUrl,
testInstanceUrl,
);
expect(register.deletedWithKeys.single?.attempt, attempt);
});
test('of an anonymous person deletes it here alone, once the '
'register says it has no such account', () async {
await endpoints.account.delete(asAnna());
expect(register.asked, ['knownAccounts']);
expect(await AuthUser.db.findById(session, anna), isNull);
expect(hostDeleted, [anna]);
});
test(
'of a person the register knows deletes it at the register, '
"whatever this session's scopes say (the full reading's L7)",
() async {
// Linked at the register, her becoming identified here failed.
register.accounts.add(anna);
await endpoints.account.delete(asAnna());
expect(register.deleted, [anna]);
},
);
test('is refused while the register is out of reach, deleting '
'nothing here', () async {
register.failing = RegisterUnavailableException('test');
await expectLater(
endpoints.account.delete(asBen()),
throwsA(isA<RegisterUnavailableException>()),
);
expect(await AuthUser.db.findById(session, ben), isNotNull);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
1,
);
expect(hostDeleted, isEmpty);
});
test('asks the register first: tries while it is out of reach count '
"for nothing, as no proof left here (the reading of the fixes' "
'B1)', () async {
register.failing = RegisterUnavailableException('test');
DeletionProof proof() => DeletionProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '123456',
instanceUrl: testInstanceUrl,
);
for (var i = 0; i < 11; i++) {
await expectLater(
endpoints.account.delete(asBen(), proof: proof()),
throwsA(isA<RegisterUnavailableException>()),
);
}
expect(register.asked.toSet(), {'knownAccounts'});
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNotNull,
);
});
test('of a person anonymous here is refused too while the register '
'is out of reach, deleting nothing here: the register may have '
'linked her while her becoming identified here failed (the '
'short reading, L2)', () async {
register.failing = RegisterUnavailableException('test');
await expectLater(
endpoints.account.delete(asAnna()),
throwsA(isA<RegisterUnavailableException>()),
);
expect(await AuthUser.db.findById(session, anna), isNotNull);
expect(
await ServerSideSession.db.findById(session, annasPhone),
isNotNull,
);
expect(hostDeleted, isEmpty);
});
});
group('told by the register that an account is gone', () {
Future<InstanceAnswer> tell(
Object? account, {
String? authorization = 'Bearer $_secret',
}) => answerRegister(
session,
jsonEncode({'account': account}),
authorization: authorization,
);
test('deletes that person here with their sessions, and nobody '
'else', () async {
final answer = await tell(ben.uuid);
expect(answer.status, 200);
expect(await AuthUser.db.findById(session, ben), isNull);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
0,
);
expect(hostDeleted, [ben]);
expect(await AuthUser.db.findById(session, anna), isNotNull);
expect(register.asked, isEmpty);
});
test('confirms an account it never had', () async {
expect((await tell(const Uuid().v7())).status, 200);
expect(hostDeleted, isEmpty);
});
test('refuses without the instance secret, or with a wrong one, '
'deleting nobody', () async {
for (final authorization in [
null,
'Bearer wrong',
'Basic $_secret',
]) {
expect(
(await tell(ben.uuid, authorization: authorization)).status,
401,
);
}
useInstanceSecret(pod, null);
expect((await tell(ben.uuid)).status, 401);
expect(await AuthUser.db.findById(session, ben), isNotNull);
expect(hostDeleted, isEmpty);
});
test('refuses what it cannot read', () async {
for (final account in [null, 7, 'not an id']) {
expect((await tell(account)).status, 400, reason: '$account');
}
expect(
(await answerRegister(
session,
'not JSON',
authorization: 'Bearer $_secret',
)).status,
400,
);
expect(await AuthUser.db.findById(session, ben), isNotNull);
});
test('is answered by no register', () async {
useBase(sessionBuilder);
expect((await tell(ben.uuid)).status, 404);
expect(await AuthUser.db.findById(session, ben), isNotNull);
});
});
group('told by the register that sessions ended (ADR 0017)', () {
Future<InstanceAnswer> tell(
Map<String, Object?> body, {
String? authorization = 'Bearer $_secret',
}) => answerSessionsEnded(
session,
jsonEncode(body),
authorization: authorization,
);
Future<DateTime> createdAt(UuidValue id) async =>
(await ServerSideSession.db.findById(session, id))!.createdAt;
test('ends those of the account made before the moment, but the one '
'kept and those made since, and nobody else', () async {
final bensPhone = await signInNewDevice(session, ben);
final endedBefore = (await createdAt(
bensPhone,
)).add(const Duration(microseconds: 1));
await Future<void>.delayed(const Duration(milliseconds: 5));
final bensTablet = await signInNewDevice(session, ben);
final answer = await tell({
'account': ben.uuid,
'endedBefore': endedBefore.toIso8601String(),
'keepSession': bensLaptop.uuid,
});
expect(answer.status, 200);
final left = {
for (final row in await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben),
))
row.id!,
};
expect(left, {bensLaptop, bensTablet});
expect(
await ServerSideSession.db.findById(session, annasPhone),
isNotNull,
);
expect(register.asked, isEmpty);
});
test(
"a moment sent with the register's clock is taken by this "
"server's own: a clock ahead ends the sessions made here in its "
"gap, those made since stay (the reading of the fixes' C1)",
() async {
// This server runs 60 s ahead of the register. By its clock, a
// session was made 20 s ago, the register ended the account's
// sessions 10 s ago, and another was made 5 s ago.
final now = DateTime.now().toUtc();
Future<UuidValue> madeAgo(Duration ago) async {
final id = await signInNewDevice(session, ben);
final row = (await ServerSideSession.db.findById(session, id))!;
await ServerSideSession.db.updateRow(
session,
row.copyWith(createdAt: now.subtract(ago)),
);
return id;
}
final inTheGap = await madeAgo(const Duration(seconds: 20));
final since = await madeAgo(const Duration(seconds: 5));
const ahead = Duration(seconds: 60);
final endedBefore = now
.subtract(const Duration(seconds: 10))
.subtract(ahead);
final answer = await tell({
'account': ben.uuid,
'endedBefore': endedBefore.toIso8601String(),
'sentAt': now.subtract(ahead).toIso8601String(),
});
expect(answer.status, 200);
expect(
await ServerSideSession.db.findById(session, inTheGap),
isNull,
);
expect(
await ServerSideSession.db.findById(session, since),
isNotNull,
);
},
);
test('a moment sent before it was taken (a clock set back) ends '
'every session made here until now, rather than none', () async {
final now = DateTime.now().toUtc();
final bensPhone = await signInNewDevice(session, ben);
final answer = await tell({
'account': ben.uuid,
'endedBefore': now
.subtract(const Duration(hours: 1))
.toIso8601String(),
'sentAt': now.subtract(const Duration(hours: 2)).toIso8601String(),
});
expect(answer.status, 200);
expect(
await ServerSideSession.db.findById(session, bensPhone),
isNull,
);
expect(
(await tell({
'account': ben.uuid,
'endedBefore': now.toIso8601String(),
'sentAt': 'then',
})).status,
400,
);
});
test('refuses without the secret and what it cannot read, and is '
'answered by no register, ending nothing', () async {
final everything = {
'account': ben.uuid,
'endedBefore': DateTime.now().toUtc().toIso8601String(),
};
expect((await tell(everything, authorization: null)).status, 401);
expect(
(await tell(everything, authorization: 'Bearer wrong')).status,
401,
);
expect(
(await tell({'account': ben.uuid, 'endedBefore': 'soon'})).status,
400,
);
expect((await tell({'account': ben.uuid})).status, 400);
useBase(sessionBuilder);
expect((await tell(everything)).status, 404);
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNotNull,
);
});
});
group('changing a password, by asking the register (ADR 0017)', () {
test('keeps the session asking: its ID goes to the register, which '
'tells every service', () async {
final challenge = await endpoints.password.beginChange(asBen());
await endpoints.password.change(
asBen(),
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: OwnerProof(
challenge: challenge.challenge,
clientValue: ByteData(384),
clientProof: ByteData(32),
instanceUrl: 'HTTP://LOCALHOST:0',
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: '123456',
),
password: NewPassword(
salt: ByteData(16),
costs: newPasswordCosts,
verifier: ByteData(384),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
),
);
expect(register.asked, ['beginPasswordChange', 'changePassword']);
expect(register.changed, {ben: bensLaptop});
// Bound to the base URL the device addressed here.
expect(register.changedWith.single.instanceUrl, testInstanceUrl);
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
expect(register.changedWith.single.totpCode, '123456');
});
test("counts its own session's wrong proofs, which the register "
'refuses: the tenth in a row ends the session here', () async {
register.failing = PasswordRefusedException(
reason: PasswordRefusal.wrongCredentials,
);
Future<List<String>> newCodes() =>
endpoints.authenticator.newRecoveryCodes(
asBen(),
proof: OwnerProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
instanceUrl: testInstanceUrl,
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: '000000',
),
);
for (var i = 0; i < 9; i++) {
await expectLater(
newCodes(),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
PasswordRefusal.wrongCredentials,
),
),
);
}
expect(register.warned, isEmpty);
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 expectLater(
newCodes(),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
PasswordRefusal.sessionEnded,
),
),
);
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNull,
);
// The owner's warning is the register's to keep.
expect(register.warned, {ben});
});
test('ends the session all the same when the register, down, '
"can't be told to warn its owner", () async {
register
..failing = PasswordRefusedException(
reason: PasswordRefusal.wrongCredentials,
)
..notingFails = RegisterUnavailableException('test');
for (var i = 0; i < 10; i++) {
await expectLater(
endpoints.authenticator.newRecoveryCodes(
asBen(),
proof: OwnerProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
instanceUrl: testInstanceUrl,
totpCode: '000000',
),
),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
i < 9
? PasswordRefusal.wrongCredentials
: PasswordRefusal.sessionEnded,
),
),
);
}
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNull,
);
expect(register.asked.last, 'noteSessionEndedForProofs');
expect(register.warned, isEmpty);
});
test("passes its person's calling off of resets to the register, "
'never one signed out', () async {
await endpoints.password.callOffResets(
asBen(),
proof: OwnerProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '123456',
instanceUrl: testInstanceUrl,
),
);
expect(register.asked, contains('callOffResets'));
register.asked.clear();
await expectLater(
endpoints.password.callOffResets(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
proof: OwnerProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
instanceUrl: testInstanceUrl,
),
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
expect(register.asked, isNot(contains('callOffResets')));
});
test("passes its person's dismissing of the warning to the register, "
"never another's", () async {
await endpoints.password.dismissSessionEndedWarning(asBen());
expect(register.dismissed, {ben});
// With when Ben's session here was made (the full reading's L1).
expect(register.dismissedMadeAt[ben], isNotNull);
await expectLater(
endpoints.password.dismissSessionEndedWarning(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
expect(register.dismissed, {ben});
});
test('a reset ends the sessions here made before the register ended '
'them, the new one made after', () async {
register
..proof = PasswordProof(
account: ben,
serverProof: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
needsAuthenticator: false,
)
..endedBefore = DateTime.now().toUtc();
final signedIn = await endpoints.accountRecovery.resetPassword(
notSignedIn(),
challenge: const Uuid().v7obj(),
signature: ByteData(64),
password: NewPassword(
salt: ByteData(16),
costs: newPasswordCosts,
verifier: ByteData(384),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
),
totpCode: '123456',
instanceUrl: testInstanceUrl,
address: 'anna@example.org',
language: 'en',
);
expect(signedIn.authUserId, ben);
final left = await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben),
);
expect(left.single.id, isNot(bensLaptop));
expect(
left.single.createdAt.isBefore(register.endedBefore),
isFalse,
);
});
PasswordProof bensProof() => PasswordProof(
account: ben,
serverProof: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
needsAuthenticator: false,
);
Future<AuthSuccess> resetBensPassword() =>
endpoints.accountRecovery.resetPassword(
notSignedIn(),
challenge: const Uuid().v7obj(),
signature: ByteData(64),
password: NewPassword(
salt: ByteData(16),
costs: newPasswordCosts,
verifier: ByteData(384),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
),
totpCode: '123456',
instanceUrl: testInstanceUrl,
address: 'anna@example.org',
language: 'en',
);
test("a clock behind the register's neither waits nor refuses: the "
"sessions here end, and the new one stays when the register's "
'notice, sent with its clock, arrives', () async {
final endedBefore = DateTime.now().toUtc().add(
const Duration(minutes: 1),
);
register
..proof = bensProof()
..endedBefore = endedBefore;
final watch = Stopwatch()..start();
final signedIn = await resetBensPassword();
expect(watch.elapsed, lessThan(const Duration(seconds: 5)));
final left = await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben),
);
expect(left.single.id, isNot(bensLaptop));
expect(signedIn.authUserId, ben);
final answer = await answerSessionsEnded(
session,
jsonEncode({
'account': ben.uuid,
'endedBefore': endedBefore.toIso8601String(),
'sentAt': endedBefore
.add(const Duration(seconds: 1))
.toIso8601String(),
}),
authorization: 'Bearer $_secret',
);
expect(answer.status, 200);
expect(
await ServerSideSession.db.findById(session, left.single.id!),
isNotNull,
);
});
test("the register's notice of a reset, however long it took to "
'arrive, leaves the session made here for it; a notice of a '
'later moment ends it (the short reading, M2)', () async {
final endedBefore = DateTime.now().toUtc();
register
..proof = bensProof()
..endedBefore = endedBefore;
final signedIn = await resetBensPassword();
final made = (await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben),
)).single.id!;
expect(signedIn.authUserId, ben);
// Its way here took longer than the reset's answer: sent at once,
// it arrives after the session was made.
await Future<void>.delayed(const Duration(milliseconds: 20));
final answer = await answerSessionsEnded(
session,
jsonEncode({
'account': ben.uuid,
'endedBefore': endedBefore.toIso8601String(),
'sentAt': endedBefore.toIso8601String(),
}),
authorization: 'Bearer $_secret',
);
expect(answer.status, 200);
expect(await ServerSideSession.db.findById(session, made), isNotNull);
final later = DateTime.now().toUtc();
await answerSessionsEnded(
session,
jsonEncode({
'account': ben.uuid,
'endedBefore': later.toIso8601String(),
'sentAt': later.toIso8601String(),
}),
authorization: 'Bearer $_secret',
);
expect(await ServerSideSession.db.findById(session, made), isNull);
});
test("a notice of the reset's moment for another account keeps "
"nothing of Ben's", () async {
final endedBefore = DateTime.now().toUtc();
register
..proof = bensProof()
..endedBefore = endedBefore;
await resetBensPassword();
final annasTablet = await signInNewDevice(session, anna);
await Future<void>.delayed(const Duration(milliseconds: 5));
await answerSessionsEnded(
session,
jsonEncode({
'account': anna.uuid,
'endedBefore': endedBefore.toIso8601String(),
'sentAt': endedBefore.toIso8601String(),
}),
authorization: 'Bearer $_secret',
);
expect(
await ServerSideSession.db.findById(session, annasTablet),
isNull,
);
});
test("a clock ahead of the register's ends the sessions made here in "
"its gap too (the reading of the fixes' C1)", () async {
final now = DateTime.now().toUtc();
register
..proof = bensProof()
..endedBefore = now.subtract(const Duration(minutes: 1));
// Whoever knew the old password signed in here 20 s before the
// reset: by this server's clock, 40 s after the register's moment.
final inTheGap = await signInNewDevice(
session,
ben,
scopes: {identified},
);
final row = (await ServerSideSession.db.findById(session, inTheGap))!;
await ServerSideSession.db.updateRow(
session,
row.copyWith(createdAt: now.subtract(const Duration(seconds: 20))),
);
final signedIn = await resetBensPassword();
expect(signedIn.authUserId, ben);
expect(
await ServerSideSession.db.findById(session, inTheGap),
isNull,
);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
1,
);
});
});
group('pruning', () {
late UuidValue carla;
setUp(() async {
carla = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
});
test('deletes the identified people the register no longer knows, '
'asking only about those', () async {
final forgotten = await forgetPeopleTheRegisterForgot(
session,
batch: 1,
);
expect(forgotten, 1);
expect(await AuthUser.db.findById(session, carla), isNull);
expect(await AuthUser.db.findById(session, ben), isNotNull);
expect(await AuthUser.db.findById(session, anna), isNotNull);
expect(register.askedAbout.expand((people) => people), {ben, carla});
});
test('deletes nobody while the register knows none of those asked '
'about, as a register misnamed or emptied would say', () async {
register.accounts.clear();
expect(await forgetPeopleTheRegisterForgot(session), 0);
expect(await AuthUser.db.findById(session, ben), isNotNull);
expect(await AuthUser.db.findById(session, carla), isNotNull);
});
test('deletes nobody while the register is out of reach', () async {
register.failing = RegisterUnavailableException('test');
await expectLater(
forgetPeopleTheRegisterForgot(session),
throwsA(isA<RegisterUnavailableException>()),
);
expect(await AuthUser.db.findById(session, ben), isNotNull);
expect(await AuthUser.db.findById(session, carla), isNotNull);
});
test('does nothing on the register', () async {
useBase(sessionBuilder);
expect(await forgetPeopleTheRegisterForgot(session), 0);
expect(register.asked, isEmpty);
expect(await AuthUser.db.findById(session, carla), isNotNull);
});
});
test('does not start without the secret where it would need it, and '
'needs none alone', () {
expect(() => checkInstanceSecret(pod), returnsNormally);
useInstanceSecret(pod, null);
expect(() => checkInstanceSecret(pod), throwsStateError);
useBase(sessionBuilder, accounts: HoldsRegister(members: [_learn]));
expect(() => checkInstanceSecret(pod), throwsStateError);
useBase(sessionBuilder);
expect(() => checkInstanceSecret(pod), returnsNormally);
});
test('does not start with a secret shorter than 32 characters, alone '
'or not: it gates every account of the instance', () {
for (final accounts in [
const HoldsRegister(),
HoldsRegister(members: [_learn]),
MemberOfRegister(_learn),
]) {
useBase(sessionBuilder, accounts: accounts);
useInstanceSecret(pod, 'x' * 31);
expect(() => checkInstanceSecret(pod), throwsStateError);
useInstanceSecret(pod, 'x' * 32);
expect(() => checkInstanceSecret(pod), returnsNormally);
}
});
test('asks the register at its address with the secret, and asks '
'nothing without one', () {
AccountRegister.use(pod, null);
expect(
AccountRegister.of(pod),
isA<RemoteRegister>()
.having((remote) => remote.register, 'register', _learn)
.having((remote) => remote.secret, 'secret', _secret),
);
useInstanceSecret(pod, null);
expect(() => AccountRegister.of(pod), throwsStateError);
expect(
MemberNotifier.of(pod),
isA<HttpMemberNotifier>().having(
(notifier) => notifier.secret,
'secret',
isNull,
),
);
});
},
);
}
Future<void> _deleteAll(Session session) async {
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
}