2026-10-19 23:10:08 +02:00
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).
- An account with an address proves it anew: a code mailed to that
address for this (EmailCodePurpose.firstPassword, its own mail), never
the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
a GitHub sign-in (the session's method and creation time, where the
session lives; members tell the register), refused signInAgain before
any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
they add.
The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:31:22 +02:00
|
|
|
import 'package:clock/clock.dart';
|
2026-10-19 23:10:08 +02:00
|
|
|
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:serverpod/serverpod.dart';
|
|
|
|
|
import 'package:serverpod_auth_idp_server/core.dart';
|
|
|
|
|
import 'package:test/test.dart';
|
|
|
|
|
|
|
|
|
|
import '../support/fixtures.dart';
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
import '../support/password_device.dart';
|
2026-10-19 23:10:08 +02:00
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
/// The accounts with a password, and those with an authenticator too.
|
|
|
|
|
final passwords = <UuidValue>{};
|
|
|
|
|
final authenticated = <UuidValue>{};
|
|
|
|
|
|
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).
- An account with an address proves it anew: a code mailed to that
address for this (EmailCodePurpose.firstPassword, its own mail), never
the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
a GitHub sign-in (the session's method and creation time, where the
session lives; members tell the register), refused signInAgain before
any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
they add.
The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:31:22 +02:00
|
|
|
/// The people a password was set for with a GitHub sign-in of the
|
|
|
|
|
/// member's made just now.
|
|
|
|
|
final freshGitHubSignIns = <UuidValue>{};
|
|
|
|
|
|
2026-10-21 13:32:33 +02:00
|
|
|
/// When the session dismissing a warning was made, by person.
|
|
|
|
|
final dismissedMadeAt = <UuidValue, DateTime?>{};
|
|
|
|
|
|
fix(auth): GitHub-only accounts go at GitHub's end; only the anonymous add an address
The full reading's M1 and L6: once GitHub IDs were forgotten at
GITHUB_SIGN_IN_ENDS, an account that had signed in with GitHub alone
counted as anonymous, so a stolen session and any mailbox gave it a first
password and authenticator; and such accounts, with no way in left, were
never pruned. Now:
- at the end, the daily run that forgets the GitHub IDs first deletes the
accounts that sign in with GitHub alone (no address, so never a
password), with everything they have, members told (deletePerson); the
app tells them for the three months before that their account goes then
unless they set up a password (passwordSectionGitHubEndsDeletes, en/de);
- password.set adds an address without GitHub only for someone anonymous
where the session lives (anonymousHere, which a member tells the
register, as it tells freshGitHubSignIn); anyone else is refused before
any code is used.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:23:40 +02:00
|
|
|
/// 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;
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
/// Thrown by every call while set: the register refusing, or down.
|
|
|
|
|
Exception? failing;
|
|
|
|
|
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
/// The base URLs sign-ins were bound to, as members named them.
|
|
|
|
|
final instanceUrls = <String>[];
|
fix(auth)!: proofs are bound to the instance the device addresses
Nothing tied a sign-in's proof to the instance it was made for: an
instance a person was lured into adding could pass A, M1 and the code on
to the real register and get a session there, and pass a phrase's proof
on with a password of its own in place of the person's (the second
reading's M6). SRP's identity is now a label, the challenge's ID and
the host the device addressed (passwordIdentity, in halkyon_base_client
and halkyon_base_server alike), and the phrase's proof signs that host,
what it is for (an authenticator's reset or a new password) and the new
password's digest (key proof v2). Every proving call carries the host;
in production it must be the server's apiServer.publicHost (otherwise
otherInstance, which the app shows as an instance it can't trust), in
development any; a member forwards its own. The tests prove that a
relayed sign-in, a proof for another host or purpose, and a swapped
password are refused, nothing changing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:20:12 +02:00
|
|
|
|
2026-10-21 12:05:18 +02:00
|
|
|
/// 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;
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
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,
|
|
|
|
|
}) => _answer(
|
|
|
|
|
'saveProfile',
|
|
|
|
|
() => 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,
|
|
|
|
|
) => _answer('checkHandle', () => null);
|
|
|
|
|
|
|
|
|
|
@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,
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
required EmailCodePurpose purpose,
|
2026-10-19 23:10:08 +02:00
|
|
|
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),
|
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),
|
2026-10-20 14:16:23 +02:00
|
|
|
passwordOnly:
|
|
|
|
|
passwords.contains(person) && authenticated.contains(person),
|
2026-10-19 23:10:08 +02:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-21 12:05:18 +02:00
|
|
|
@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
|
2026-10-21 13:32:33 +02:00
|
|
|
Future<void> dismissSessionEndedWarning(
|
|
|
|
|
Session session,
|
|
|
|
|
UuidValue person, {
|
|
|
|
|
required DateTime? sessionMadeAt,
|
|
|
|
|
}) => _answer('dismissSessionEndedWarning', () {
|
|
|
|
|
dismissed.add(person);
|
|
|
|
|
dismissedMadeAt[person] = sessionMadeAt;
|
|
|
|
|
});
|
2026-10-21 12:05:18 +02:00
|
|
|
|
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<void> setPassword(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue person,
|
|
|
|
|
required PasswordSetup setup,
|
|
|
|
|
UuidValue? request,
|
|
|
|
|
String? code,
|
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).
- An account with an address proves it anew: a code mailed to that
address for this (EmailCodePurpose.firstPassword, its own mail), never
the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
a GitHub sign-in (the session's method and creation time, where the
session lives; members tell the register), refused signInAgain before
any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
they add.
The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:31:22 +02:00
|
|
|
bool freshGitHubSignIn = false,
|
fix(auth): GitHub-only accounts go at GitHub's end; only the anonymous add an address
The full reading's M1 and L6: once GitHub IDs were forgotten at
GITHUB_SIGN_IN_ENDS, an account that had signed in with GitHub alone
counted as anonymous, so a stolen session and any mailbox gave it a first
password and authenticator; and such accounts, with no way in left, were
never pruned. Now:
- at the end, the daily run that forgets the GitHub IDs first deletes the
accounts that sign in with GitHub alone (no address, so never a
password), with everything they have, members told (deletePerson); the
app tells them for the three months before that their account goes then
unless they set up a password (passwordSectionGitHubEndsDeletes, en/de);
- password.set adds an address without GitHub only for someone anonymous
where the session lives (anonymousHere, which a member tells the
register, as it tells freshGitHubSignIn); anyone else is refused before
any code is used.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:23:40 +02:00
|
|
|
bool anonymousHere = false,
|
2026-10-21 13:34:00 +02:00
|
|
|
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);
|
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).
- An account with an address proves it anew: a code mailed to that
address for this (EmailCodePurpose.firstPassword, its own mail), never
the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
a GitHub sign-in (the session's method and creation time, where the
session lives; members tell the register), refused signInAgain before
any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
they add.
The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:31:22 +02:00
|
|
|
if (freshGitHubSignIn) {
|
|
|
|
|
freshGitHubSignIns.add(person);
|
|
|
|
|
}
|
fix(auth): GitHub-only accounts go at GitHub's end; only the anonymous add an address
The full reading's M1 and L6: once GitHub IDs were forgotten at
GITHUB_SIGN_IN_ENDS, an account that had signed in with GitHub alone
counted as anonymous, so a stolen session and any mailbox gave it a first
password and authenticator; and such accounts, with no way in left, were
never pruned. Now:
- at the end, the daily run that forgets the GitHub IDs first deletes the
accounts that sign in with GitHub alone (no address, so never a
password), with everything they have, members told (deletePerson); the
app tells them for the three months before that their account goes then
unless they set up a password (passwordSectionGitHubEndsDeletes, en/de);
- password.set adds an address without GitHub only for someone anonymous
where the session lives (anonymousHere, which a member tells the
register, as it tells freshGitHubSignIn); anyone else is refused before
any code is used.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:23:40 +02:00
|
|
|
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,
|
2026-10-20 14:41:16 +02:00
|
|
|
UuidValue? request,
|
|
|
|
|
String? code,
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
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,
|
fix(auth)!: proofs are bound to the instance the device addresses
Nothing tied a sign-in's proof to the instance it was made for: an
instance a person was lured into adding could pass A, M1 and the code on
to the real register and get a session there, and pass a phrase's proof
on with a password of its own in place of the person's (the second
reading's M6). SRP's identity is now a label, the challenge's ID and
the host the device addressed (passwordIdentity, in halkyon_base_client
and halkyon_base_server alike), and the phrase's proof signs that host,
what it is for (an authenticator's reset or a new password) and the new
password's digest (key proof v2). Every proving call carries the host;
in production it must be the server's apiServer.publicHost (otherwise
otherInstance, which the app shows as an instance it can't trust), in
development any; a member forwards its own. The tests prove that a
relayed sign-in, a proof for another host or purpose, and a swapped
password are refused, nothing changing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:20:12 +02:00
|
|
|
}) {
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrls.add(instanceUrl);
|
fix(auth)!: proofs are bound to the instance the device addresses
Nothing tied a sign-in's proof to the instance it was made for: an
instance a person was lured into adding could pass A, M1 and the code on
to the real register and get a session there, and pass a phrase's proof
on with a password of its own in place of the person's (the second
reading's M6). SRP's identity is now a label, the challenge's ID and
the host the device addressed (passwordIdentity, in halkyon_base_client
and halkyon_base_server alike), and the phrase's proof signs that host,
what it is for (an authenticator's reset or a new password) and the new
password's digest (key proof v2). Every proving call carries the host;
in production it must be the server's apiServer.publicHost (otherwise
otherInstance, which the app shows as an instance it can't trust), in
development any; a member forwards its own. The tests prove that a
relayed sign-in, a proof for another host or purpose, and a swapped
password are refused, nothing changing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:20:12 +02:00
|
|
|
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
|
|
|
|
2026-10-20 14:41:16 +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,
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
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)),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
/// 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
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
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,
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
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,
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
}) => _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
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
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,
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
}) => _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,
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
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);
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
});
|
|
|
|
|
|
fix(auth): a reset waits while its owner is held out, and devices show it
The full reading's H1: an owner could be kept from calling a reset off, and
the reset then went through. Whoever knew the password and had the mailbox
asked an authenticators' reset and kept the codes waiting, so the owner's
sign-ins were refused until it took effect; whoever had the mailbox filled
the address's proofs ceiling from three networks and completed an account
reset. Now every hold-out of the owner after a reset was asked (a wait
that begins, an attempt a wait refuses, a sign-in or proof refused by the
address's ceiling) moves the account's resets not carried out to take
effect no sooner than 72 hours after it ends (pushOutResets). A reset
carried out (the password alone signing in until an authenticator is
confirmed) is never pushed, nor a lapsed one brought back.
Signed-in devices are told too: SignInWays carries when a waiting reset
of each kind takes effect, the account section shows them with Call it
off, which takes the owner's proof (password and code; password.
callOffResets, forwarded by members) and calls every reset off, as no wait
holds a signed-in owner's proof back. Texts in English and German.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:17:30 +02:00
|
|
|
@override
|
|
|
|
|
Future<void> callOffResets(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue person,
|
|
|
|
|
required OwnerProof proof,
|
|
|
|
|
}) => _answer('callOffResets', () {});
|
|
|
|
|
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
@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,
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
required WrappedKeys recoveryKeys,
|
fix(auth)!: the recovery phrase is proved by a key of its own
A key proof was a signature by the account's signing key, which every
signed-in device keeps in its keyring: whoever could read one, with a
code from its authenticator, set a new password without the password or
the phrase (the second reading's H1). The phrase now proves itself with
an Ed25519 key derived from its bits alone (halkyon_keys'
recoveryProofKeysOf, KDF context hlkrprf1, a sibling of the recovery
key), whose public half every setup, new phrase and account reset sends
and the register keeps (account_password.recoveryProofKey); the device
derives the secret half only while the phrase is typed. An account
without one (none exist outside development) proves no phrase until it
sets a new one; decoys check against a decoy proof key alike.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_password" ADD COLUMN "recoveryProofKey" bytea;
nullable, so rows made before keep working and no data is touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:03:15 +02:00
|
|
|
required ByteData recoveryProofKey,
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
}) => _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
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
@override
|
2026-10-20 21:59:42 +02:00
|
|
|
Future<void> deleteAccount(
|
|
|
|
|
Session session,
|
|
|
|
|
UuidValue person, {
|
|
|
|
|
DeletionProof? proof,
|
|
|
|
|
}) => _answer('deleteAccount', () {
|
|
|
|
|
deleted.add(person);
|
|
|
|
|
deletedWith.add(proof);
|
|
|
|
|
accounts.remove(person);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// The proofs deletions came with, as the member bound them.
|
|
|
|
|
final deletedWith = <DeletionProof?>[];
|
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>[];
|
2026-10-19 23:10:08 +02:00
|
|
|
|
|
|
|
|
@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',
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
purpose: EmailCodePurpose.signUp,
|
2026-10-19 23:10:08 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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',
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
purpose: EmailCodePurpose.signUp,
|
2026-10-19 23:10:08 +02:00
|
|
|
),
|
|
|
|
|
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',
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrl: testInstanceUrl,
|
fix(auth): every call with a code after the password takes the address
Whoever guessed codes decided whether the owner was mailed (the re-review's
fourth finding): the address a sign-in sent was optional, so a guesser left
it out and no mail went, and a wait begun without a mail used up the one
mail there was. Every call carrying a code after the password or the phrase
proved (signing in, the phrase's reset, a change of password, a new phrase,
deletion) now takes the account's address and the language of its mails,
and is refused (wrongCredentials, as anything wrong after the password)
before any code is checked when the address isn't the account's; every
wait mails the owner, not only the first. The app sends the address the
person typed, or for changes while signed in the one this device signed in
with, and says so where it doesn't know it (addressUnknown).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:08:25 +02:00
|
|
|
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));
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
// 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,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
fix(auth)!: the first authenticator takes the password, and a reset ends every session
The fourth reading's second finding: a stolen session outlived the reset
of the authenticators (carrying it out ended no session) and confirmed
the account's first authenticator on its own code alone, ahead of the
owner, so the owner's confirmation and sign-in were refused after.
- Carrying out a reset of the authenticators ends every session of the
account, on the register at once and on every member by notice, as the
other resets do; the sign-in that carries it out gets its session after
that moment (PasswordProof.sessionsEndedBefore, issueSessionAfter).
- Confirming the first authenticator takes the password's proof for a
change's challenge, without a code (proveOwnersPassword), counted per
session as the owner's proofs are: a session of a lost phone, or of a
setup never finished, claims no second factor.
The app sends the password with every confirmation: the sign-up's, a
reset's and the account section's (which asks for it), and a sign-in's
own unlock where the session is still held back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:43:22 +02:00
|
|
|
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),
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrl: testInstanceUrl,
|
fix(auth)!: the first authenticator takes the password, and a reset ends every session
The fourth reading's second finding: a stolen session outlived the reset
of the authenticators (carrying it out ended no session) and confirmed
the account's first authenticator on its own code alone, ahead of the
owner, so the owner's confirmation and sign-in were refused after.
- Carrying out a reset of the authenticators ends every session of the
account, on the register at once and on every member by notice, as the
other resets do; the sign-in that carries it out gets its session after
that moment (PasswordProof.sessionsEndedBefore, issueSessionAfter).
- Confirming the first authenticator takes the password's proof for a
change's challenge, without a code (proveOwnersPassword), counted per
session as the owner's proofs are: a session of a lost phone, or of a
setup never finished, claims no second factor.
The app sends the password with every confirmation: the sign-up's, a
reset's and the account section's (which asks for it), and a sign-in's
own unlock where the session is still held back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:43:22 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-21 11:46:01 +02:00
|
|
|
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),
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrl: testInstanceUrl,
|
2026-10-21 11:46:01 +02:00
|
|
|
totpCode: '123456',
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
throwsA(isA<RegisterUnavailableException>()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
(await SessionCodeFailures.db.findFirstRow(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.sessionId.equals(annasPhone),
|
|
|
|
|
))?.failures,
|
|
|
|
|
1,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-20 14:41:16 +02:00
|
|
|
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),
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrl: testInstanceUrl,
|
fix(auth): every call with a code after the password takes the address
Whoever guessed codes decided whether the owner was mailed (the re-review's
fourth finding): the address a sign-in sent was optional, so a guesser left
it out and no mail went, and a wait begun without a mail used up the one
mail there was. Every call carrying a code after the password or the phrase
proved (signing in, the phrase's reset, a change of password, a new phrase,
deletion) now takes the account's address and the language of its mails,
and is refused (wrongCredentials, as anything wrong after the password)
before any code is checked when the address isn't the account's; every
wait mails the owner, not only the first. The app sends the address the
person typed, or for changes while signed in the one this device signed in
with, and says so where it doesn't know it (addressUnknown).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:08:25 +02:00
|
|
|
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});
|
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).
- An account with an address proves it anew: a code mailed to that
address for this (EmailCodePurpose.firstPassword, its own mail), never
the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
a GitHub sign-in (the session's method and creation time, where the
session lives; members tell the register), refused signInAgain before
any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
they add.
The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:31:22 +02:00
|
|
|
expect(register.freshGitHubSignIns, isEmpty);
|
fix(auth): GitHub-only accounts go at GitHub's end; only the anonymous add an address
The full reading's M1 and L6: once GitHub IDs were forgotten at
GITHUB_SIGN_IN_ENDS, an account that had signed in with GitHub alone
counted as anonymous, so a stolen session and any mailbox gave it a first
password and authenticator; and such accounts, with no way in left, were
never pruned. Now:
- at the end, the daily run that forgets the GitHub IDs first deletes the
accounts that sign in with GitHub alone (no address, so never a
password), with everything they have, members told (deletePerson); the
app tells them for the three months before that their account goes then
unless they set up a password (passwordSectionGitHubEndsDeletes, en/de);
- password.set adds an address without GitHub only for someone anonymous
where the session lives (anonymousHere, which a member tells the
register, as it tells freshGitHubSignIn); anyone else is refused before
any code is used.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:23:40 +02:00
|
|
|
// 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,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).
- An account with an address proves it anew: a code mailed to that
address for this (EmailCodePurpose.firstPassword, its own mail), never
the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
a GitHub sign-in (the session's method and creation time, where the
session lives; members tell the register), refused signInAgain before
any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
they add.
The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:31:22 +02:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
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(
|
|
|
|
|
(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 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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-20 21:59:42 +02:00
|
|
|
test("passes the password's proof on to the register, bound to "
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
'the base URL the device addressed here', () async {
|
2026-10-20 21:59:42 +02:00
|
|
|
await endpoints.account.delete(
|
|
|
|
|
asBen(),
|
|
|
|
|
proof: DeletionProof(
|
|
|
|
|
challenge: const Uuid().v7obj(),
|
|
|
|
|
clientValue: ByteData(384),
|
|
|
|
|
clientProof: ByteData(32),
|
|
|
|
|
totpCode: '123456',
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrl: 'HTTP://LOCALHOST:0',
|
2026-10-20 21:59:42 +02:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(register.deleted, [ben]);
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
expect(register.deletedWith.single?.instanceUrl, testInstanceUrl);
|
2026-10-20 21:59:42 +02:00
|
|
|
expect(register.deletedWith.single?.totpCode, '123456');
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-21 13:35:42 +02:00
|
|
|
test('of an anonymous person deletes it here alone, once the '
|
|
|
|
|
'register says it has no such account', () async {
|
2026-10-19 23:10:08 +02:00
|
|
|
await endpoints.account.delete(asAnna());
|
|
|
|
|
|
2026-10-21 13:35:42 +02:00
|
|
|
expect(register.asked, ['knownAccounts']);
|
2026-10-19 23:10:08 +02:00
|
|
|
expect(await AuthUser.db.findById(session, anna), isNull);
|
|
|
|
|
expect(hostDeleted, [anna]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-21 13:35:42 +02:00
|
|
|
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]);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
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),
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
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',
|
|
|
|
|
),
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
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});
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
// 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),
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-21 12:05:18 +02:00
|
|
|
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,
|
|
|
|
|
);
|
2026-10-21 12:05:18 +02:00
|
|
|
// 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),
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrl: testInstanceUrl,
|
2026-10-21 12:05:18 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
fix(auth): a reset waits while its owner is held out, and devices show it
The full reading's H1: an owner could be kept from calling a reset off, and
the reset then went through. Whoever knew the password and had the mailbox
asked an authenticators' reset and kept the codes waiting, so the owner's
sign-ins were refused until it took effect; whoever had the mailbox filled
the address's proofs ceiling from three networks and completed an account
reset. Now every hold-out of the owner after a reset was asked (a wait
that begins, an attempt a wait refuses, a sign-in or proof refused by the
address's ceiling) moves the account's resets not carried out to take
effect no sooner than 72 hours after it ends (pushOutResets). A reset
carried out (the password alone signing in until an authenticator is
confirmed) is never pushed, nor a lapsed one brought back.
Signed-in devices are told too: SignInWays carries when a waiting reset
of each kind takes effect, the account section shows them with Call it
off, which takes the owner's proof (password and code; password.
callOffResets, forwarded by members) and calls every reset off, as no wait
holds a signed-in owner's proof back. Texts in English and German.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:17:30 +02:00
|
|
|
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',
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrl: testInstanceUrl,
|
fix(auth): a reset waits while its owner is held out, and devices show it
The full reading's H1: an owner could be kept from calling a reset off, and
the reset then went through. Whoever knew the password and had the mailbox
asked an authenticators' reset and kept the codes waiting, so the owner's
sign-ins were refused until it took effect; whoever had the mailbox filled
the address's proofs ceiling from three networks and completed an account
reset. Now every hold-out of the owner after a reset was asked (a wait
that begins, an attempt a wait refuses, a sign-in or proof refused by the
address's ceiling) moves the account's resets not carried out to take
effect no sooner than 72 hours after it ends (pushOutResets). A reset
carried out (the password alone signing in until an authenticator is
confirmed) is never pushed, nor a lapsed one brought back.
Signed-in devices are told too: SignInWays carries when a waiting reset
of each kind takes effect, the account section shows them with Call it
off, which takes the owner's proof (password and code; password.
callOffResets, forwarded by members) and calls every reset off, as no wait
holds a signed-in owner's proof back. Texts in English and German.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:17:30 +02:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
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),
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrl: testInstanceUrl,
|
fix(auth): a reset waits while its owner is held out, and devices show it
The full reading's H1: an owner could be kept from calling a reset off, and
the reset then went through. Whoever knew the password and had the mailbox
asked an authenticators' reset and kept the codes waiting, so the owner's
sign-ins were refused until it took effect; whoever had the mailbox filled
the address's proofs ceiling from three networks and completed an account
reset. Now every hold-out of the owner after a reset was asked (a wait
that begins, an attempt a wait refuses, a sign-in or proof refused by the
address's ceiling) moves the account's resets not carried out to take
effect no sooner than 72 hours after it ends (pushOutResets). A reset
carried out (the password alone signing in until an authenticator is
confirmed) is never pushed, nor a lapsed one brought back.
Signed-in devices are told too: SignInWays carries when a waiting reset
of each kind takes effect, the account section shows them with Call it
off, which takes the owner's proof (password and code; password.
callOffResets, forwarded by members) and calls every reset off, as no wait
holds a signed-in owner's proof back. Texts in English and German.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:17:30 +02:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
throwsA(isA<ServerpodUnauthenticatedException>()),
|
|
|
|
|
);
|
|
|
|
|
expect(register.asked, isNot(contains('callOffResets')));
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-21 12:05:18 +02:00
|
|
|
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});
|
2026-10-21 13:32:33 +02:00
|
|
|
// With when Ben's session here was made (the full reading's L1).
|
|
|
|
|
expect(register.dismissedMadeAt[ben], isNotNull);
|
2026-10-21 12:05:18 +02:00
|
|
|
await expectLater(
|
|
|
|
|
endpoints.password.dismissSessionEndedWarning(
|
|
|
|
|
sessionBuilder.copyWith(
|
|
|
|
|
authentication: AuthenticationOverride.unauthenticated(),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
throwsA(isA<ServerpodUnauthenticatedException>()),
|
|
|
|
|
);
|
|
|
|
|
expect(register.dismissed, {ben});
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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',
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrl: testInstanceUrl,
|
fix(auth): every call with a code after the password takes the address
Whoever guessed codes decided whether the owner was mailed (the re-review's
fourth finding): the address a sign-in sent was optional, so a guesser left
it out and no mail went, and a wait begun without a mail used up the one
mail there was. Every call carrying a code after the password or the phrase
proved (signing in, the phrase's reset, a change of password, a new phrase,
deletion) now takes the account's address and the language of its mails,
and is refused (wrongCredentials, as anything wrong after the password)
before any code is checked when the address isn't the account's; every
wait mails the owner, not only the first. The app sends the address the
person typed, or for changes while signed in the one this device signed in
with, and says so where it doesn't know it (addressUnknown).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:08:25 +02:00
|
|
|
address: 'anna@example.org',
|
|
|
|
|
language: 'en',
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
});
|
fix(auth): a capped wait, a long instance secret, writes of their own columns
Three of the second reading's lows:
- A member waits at most 5 seconds (maxClockSkew) for the register's
moment before making a session after a reset; a clock further behind
is refused at once (unavailable, logged), rather than holding the
request for as long as the clocks differ.
- instanceSecret, which lets a service set passwords and delete accounts
for anyone, stops the server when set shorter than 32 characters, as
the password keys do.
- A change of password, a reset keeping the keys and a new phrase each
write their own columns alone (writeNewPassword, columns: in
setRecoveryKeys), so one made from the row as it was before another
never undoes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:30:19 +02:00
|
|
|
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
PasswordProof bensProof() => PasswordProof(
|
|
|
|
|
account: ben,
|
|
|
|
|
serverProof: ByteData(32),
|
|
|
|
|
encryptionPublicKey: ByteData(32),
|
|
|
|
|
signingPublicKey: ByteData(32),
|
|
|
|
|
passwordKeys: WrappedKeys(
|
|
|
|
|
nonce: ByteData(24),
|
|
|
|
|
cipherText: ByteData(112),
|
|
|
|
|
),
|
|
|
|
|
needsAuthenticator: false,
|
|
|
|
|
);
|
fix(auth): a capped wait, a long instance secret, writes of their own columns
Three of the second reading's lows:
- A member waits at most 5 seconds (maxClockSkew) for the register's
moment before making a session after a reset; a clock further behind
is refused at once (unavailable, logged), rather than holding the
request for as long as the clocks differ.
- instanceSecret, which lets a service set passwords and delete accounts
for anyone, stops the server when set shorter than 32 characters, as
the password keys do.
- A change of password, a reset keeping the keys and a new phrase each
write their own columns alone (writeNewPassword, columns: in
setRecoveryKeys), so one made from the row as it was before another
never undoes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:30:19 +02:00
|
|
|
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
Future<AuthSuccess> resetBensPassword() =>
|
fix(auth): a capped wait, a long instance secret, writes of their own columns
Three of the second reading's lows:
- A member waits at most 5 seconds (maxClockSkew) for the register's
moment before making a session after a reset; a clock further behind
is refused at once (unavailable, logged), rather than holding the
request for as long as the clocks differ.
- instanceSecret, which lets a service set passwords and delete accounts
for anyone, stops the server when set shorter than 32 characters, as
the password keys do.
- A change of password, a reset keeping the keys and a new phrase each
write their own columns alone (writeNewPassword, columns: in
setRecoveryKeys), so one made from the row as it was before another
never undoes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:30:19 +02:00
|
|
|
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',
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrl: testInstanceUrl,
|
fix(auth): every call with a code after the password takes the address
Whoever guessed codes decided whether the owner was mailed (the re-review's
fourth finding): the address a sign-in sent was optional, so a guesser left
it out and no mail went, and a wait begun without a mail used up the one
mail there was. Every call carrying a code after the password or the phrase
proved (signing in, the phrase's reset, a change of password, a new phrase,
deletion) now takes the account's address and the language of its mails,
and is refused (wrongCredentials, as anything wrong after the password)
before any code is checked when the address isn't the account's; every
wait mails the owner, not only the first. The app sends the address the
person typed, or for changes while signed in the one this device signed in
with, and says so where it doesn't know it (addressUnknown).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:08:25 +02:00
|
|
|
address: 'anna@example.org',
|
|
|
|
|
language: 'en',
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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',
|
fix(auth): a capped wait, a long instance secret, writes of their own columns
Three of the second reading's lows:
- A member waits at most 5 seconds (maxClockSkew) for the register's
moment before making a session after a reset; a clock further behind
is refused at once (unavailable, logged), rather than holding the
request for as long as the clocks differ.
- instanceSecret, which lets a service set passwords and delete accounts
for anyone, stops the server when set shorter than 32 characters, as
the password keys do.
- A change of password, a reset keeping the keys and a new phrase each
write their own columns alone (writeNewPassword, columns: in
setRecoveryKeys), so one made from the row as it was before another
never undoes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:30:19 +02:00
|
|
|
);
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
expect(answer.status, 200);
|
fix(auth): a capped wait, a long instance secret, writes of their own columns
Three of the second reading's lows:
- A member waits at most 5 seconds (maxClockSkew) for the register's
moment before making a session after a reset; a clock further behind
is refused at once (unavailable, logged), rather than holding the
request for as long as the clocks differ.
- instanceSecret, which lets a service set passwords and delete accounts
for anyone, stops the server when set shorter than 32 characters, as
the password keys do.
- A change of password, a reset keeping the keys and a new phrase each
write their own columns alone (writeNewPassword, columns: in
setRecoveryKeys), so one made from the row as it was before another
never undoes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:30:19 +02:00
|
|
|
expect(
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
await ServerSideSession.db.findById(session, left.single.id!),
|
|
|
|
|
isNotNull,
|
fix(auth): a capped wait, a long instance secret, writes of their own columns
Three of the second reading's lows:
- A member waits at most 5 seconds (maxClockSkew) for the register's
moment before making a session after a reset; a clock further behind
is refused at once (unavailable, logged), rather than holding the
request for as long as the clocks differ.
- instanceSecret, which lets a service set passwords and delete accounts
for anyone, stops the server when set shorter than 32 characters, as
the password keys do.
- A change of password, a reset keeping the keys and a new phrase each
write their own columns alone (writeNewPassword, columns: in
setRecoveryKeys), so one made from the row as it was before another
never undoes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:30:19 +02:00
|
|
|
);
|
|
|
|
|
});
|
2026-10-21 13:38:07 +02:00
|
|
|
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
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();
|
2026-10-21 13:38:07 +02:00
|
|
|
register
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
..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(
|
2026-10-21 13:38:07 +02:00
|
|
|
session,
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
ben,
|
|
|
|
|
scopes: {identified},
|
2026-10-21 13:38:07 +02:00
|
|
|
);
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
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),
|
2026-10-21 13:38:07 +02:00
|
|
|
),
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
1,
|
2026-10-21 13:38:07 +02:00
|
|
|
);
|
|
|
|
|
});
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
});
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
fix(auth): a capped wait, a long instance secret, writes of their own columns
Three of the second reading's lows:
- A member waits at most 5 seconds (maxClockSkew) for the register's
moment before making a session after a reset; a clock further behind
is refused at once (unavailable, logged), rather than holding the
request for as long as the clocks differ.
- instanceSecret, which lets a service set passwords and delete accounts
for anyone, stops the server when set shorter than 32 characters, as
the password keys do.
- A change of password, a reset keeping the keys and a new phrase each
write their own columns alone (writeNewPassword, columns: in
setRecoveryKeys), so one made from the row as it was before another
never undoes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:30:19 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
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));
|
|
|
|
|
}
|