2026-10-19 23:10:08 +02:00
|
|
|
import 'dart:convert';
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
import 'dart:typed_data';
|
2026-10-19 23:10:08 +02:00
|
|
|
|
|
|
|
|
import 'package:clock/clock.dart';
|
|
|
|
|
import 'package:halkyon_base_server/base.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/register/instance_api.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/register/register_protocol.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/register/remote_register.dart';
|
2026-10-21 13:28:03 +02:00
|
|
|
import 'package:halkyon_base_server/src/register/sessions_ended.dart'
|
|
|
|
|
show sessionNoticesDelivered;
|
feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)
The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.
The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:03:15 +02:00
|
|
|
import 'package:halkyon_oidc_server/oidc.dart';
|
|
|
|
|
import 'package:halkyon_oidc_server/testing.dart';
|
2026-10-19 23:10:08 +02:00
|
|
|
import 'package:http/http.dart' as http;
|
|
|
|
|
import 'package:http/testing.dart';
|
|
|
|
|
import 'package:serverpod/serverpod.dart';
|
|
|
|
|
import 'package:serverpod_auth_idp_server/core.dart';
|
|
|
|
|
import 'package:test/test.dart';
|
|
|
|
|
|
|
|
|
|
import '../support/fixtures.dart';
|
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3)
An account that signs in with SimpleLogin keeps its keys under a password
that signs nobody in (D19): `account_password` keeps the password's own
proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which
becomes nullable. The migration adds the column and drops the NOT NULL,
nothing else.
- `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity
waits under the attempt gets, in one transaction, an account
(identified), the link, a profile with the handle and the keys; then a
session (D18). Refused, the identity still waiting, for a setup out of
range and a handle refused as saving a profile refuses it.
- `simpleLogin.checkHandle(attempt, handle)`: whether someone new could
take a handle, at most 30 an hour per attempt and 60 per network.
- `simpleLogin.complete` says, for an account, whether it has its keys and
its handle, and keeps the sign-in ten minutes under its attempt, the
account as its caller: D20's confirmation for the changes to come.
- `keys.begin`, `keys.unlock`: the keys for a signed-in device once the
password is proven with its proof key, for a nonce, bound to the base
URL the device addressed; wrong proofs counted per session as ADR
0017's.
- An authenticator is enrolled only for a password that signs in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the four new operations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
|
|
|
import '../support/keys_device.dart';
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
import '../support/password_device.dart';
|
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 _todo = Uri.parse('http://todo.test:8082');
|
|
|
|
|
final _train = Uri.parse('http://train.test:8082');
|
|
|
|
|
|
|
|
|
|
/// Keeps every code mailed, in place of a mail service.
|
|
|
|
|
final class _Mailbox implements CodeMailer {
|
|
|
|
|
final sent = <({String to, String code})>[];
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> send(
|
|
|
|
|
Session session, {
|
|
|
|
|
required String to,
|
|
|
|
|
required String code,
|
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 language,
|
|
|
|
|
}) async => sent.add((to: to, code: code));
|
|
|
|
|
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
@override
|
|
|
|
|
Future<void> sendResetNotice(
|
|
|
|
|
Session session, {
|
|
|
|
|
required String to,
|
|
|
|
|
required AccountResetKind kind,
|
|
|
|
|
required DateTime takesEffectAt,
|
|
|
|
|
required String language,
|
|
|
|
|
}) async {}
|
|
|
|
|
|
fix(auth): codes wait after ten wrong in a row, and the owner is told
Whoever knew the password could guess codes at 10 an hour forever,
unnoticed, each guess checked against every authenticator (the second
reading's M4). Every code an account's second factor refuses after a
right password (sign-in, a phrase's reset, a change) or from a signed-in
device (removing an authenticator, new recovery codes, the code from one
confirmed before) now counts, per account, from every network; after
every tenth in a row no code is taken (codesWait, saying until when), an
hour, then twice as long each time, a day at the most (longestCodeWait,
second_factor_waits.dart): at a day, ten guesses a day still have about a
1 % chance a year against one authenticator, so the first wait mails the
owner that someone knows their password. The instance keeps no address,
so sign-in and the phrase's reset now send the address the device typed
(and its language), used only for that mail after its hash is checked
against the account's. A code accepted clears it all.
Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits"
bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT
NULL) with a unique index on "authUserId": a new table, nothing
touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:47:38 +02:00
|
|
|
@override
|
|
|
|
|
Future<void> sendCodesWaitNotice(
|
|
|
|
|
Session session, {
|
|
|
|
|
required String to,
|
|
|
|
|
required DateTime until,
|
|
|
|
|
required String language,
|
|
|
|
|
}) async {}
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
String codeFor(String address) =>
|
|
|
|
|
sent.lastWhere((mail) => mail.to == address).code;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The members as the register tells them, each answering as [answers]
|
|
|
|
|
/// says (confirming by default).
|
|
|
|
|
final class _Members implements MemberNotifier {
|
|
|
|
|
final told = <(Uri, UuidValue)>[];
|
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 sessions ended they were told of: member, account, moment, kept.
|
|
|
|
|
final sessionsTold = <(Uri, UuidValue, DateTime, UuidValue?)>[];
|
2026-10-19 23:10:08 +02:00
|
|
|
bool Function(Uri member) answers = (_) => true;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<bool> accountGone(Uri member, UuidValue account) async {
|
|
|
|
|
told.add((member, account));
|
|
|
|
|
return answers(member);
|
|
|
|
|
}
|
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<bool> sessionsEnded(
|
|
|
|
|
Uri member,
|
|
|
|
|
UuidValue account, {
|
|
|
|
|
required DateTime endedBefore,
|
|
|
|
|
UuidValue? keepSession,
|
|
|
|
|
}) async {
|
|
|
|
|
sessionsTold.add((member, account, endedBefore, keepSession));
|
|
|
|
|
return answers(member);
|
|
|
|
|
}
|
2026-10-19 23:10:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
withServerpod(
|
|
|
|
|
'The register answering its members',
|
|
|
|
|
// Mailing codes counts attempts in transactions of their own
|
|
|
|
|
// (Serverpod's rate limiter), which the test's rollback can't wrap.
|
|
|
|
|
rollbackDatabase: RollbackDatabase.disabled,
|
|
|
|
|
(sessionBuilder, endpoints) {
|
|
|
|
|
late Session session;
|
|
|
|
|
late Serverpod pod;
|
|
|
|
|
late _Mailbox mailbox;
|
|
|
|
|
late _Members members;
|
|
|
|
|
|
|
|
|
|
/// The register as a member's server asks it, over HTTP into this
|
|
|
|
|
/// server's answers.
|
|
|
|
|
late RemoteRegister remote;
|
|
|
|
|
|
|
|
|
|
/// Identified, signing in with ben@example.org and GitHub 1001.
|
|
|
|
|
late UuidValue ben;
|
|
|
|
|
|
|
|
|
|
setUp(() async {
|
|
|
|
|
useBase(sessionBuilder, accounts: HoldsRegister(members: [_todo]));
|
|
|
|
|
session = sessionBuilder.build();
|
|
|
|
|
pod = session.serverpod;
|
|
|
|
|
await _deleteAll(session);
|
|
|
|
|
useInstanceSecret(pod, _secret);
|
|
|
|
|
mailbox = _Mailbox();
|
|
|
|
|
EmailCodes.of(pod).mailer = mailbox;
|
|
|
|
|
members = _Members();
|
|
|
|
|
MemberNotifier.use(pod, members);
|
|
|
|
|
remote = RemoteRegister(
|
|
|
|
|
Uri.parse('http://learn.test:8082'),
|
|
|
|
|
secret: _secret,
|
|
|
|
|
client: () => MockClient((request) async {
|
|
|
|
|
final answer = await answerMember(
|
|
|
|
|
session,
|
|
|
|
|
request.url.pathSegments.last,
|
|
|
|
|
request.body,
|
|
|
|
|
authorization: request.headers['authorization'],
|
|
|
|
|
);
|
|
|
|
|
return http.Response.bytes(
|
|
|
|
|
utf8.encode(jsonEncode(answer.body)),
|
|
|
|
|
answer.status,
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
ben = (await AuthServices.instance.authUsers.create(
|
|
|
|
|
session,
|
|
|
|
|
scopes: {identified},
|
|
|
|
|
)).id;
|
|
|
|
|
await EmailIdentity.db.insertRow(
|
|
|
|
|
session,
|
|
|
|
|
EmailIdentity(
|
|
|
|
|
authUserId: ben,
|
|
|
|
|
addressHash: addressHash(pod, 'ben@example.org'),
|
|
|
|
|
createdAt: clock.now(),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await GitHubAccount.db.insertRow(
|
|
|
|
|
session,
|
|
|
|
|
GitHubAccount(userIdentifier: '1001', authUserId: ben),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
tearDown(() async {
|
|
|
|
|
EmailCodes.of(pod).mailer = null;
|
|
|
|
|
MemberNotifier.use(pod, null);
|
|
|
|
|
useInstanceSecret(pod, null);
|
|
|
|
|
await _deleteAll(sessionBuilder.build());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Future<InstanceAnswer> ask(
|
|
|
|
|
String operation,
|
|
|
|
|
Map<String, Object?> arguments, {
|
|
|
|
|
String? authorization = 'Bearer $_secret',
|
|
|
|
|
}) => answerMember(
|
|
|
|
|
session,
|
|
|
|
|
operation,
|
|
|
|
|
jsonEncode(arguments),
|
|
|
|
|
authorization: authorization,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Matcher refused(Object reason) => throwsA(
|
|
|
|
|
isA<Exception>().having(
|
|
|
|
|
(error) => refusalJson(error)?['reason'],
|
|
|
|
|
'reason',
|
|
|
|
|
(reason as Enum).name,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
group('refuses', () {
|
|
|
|
|
test('a call without the instance secret, with a wrong one or in '
|
|
|
|
|
'another scheme, doing nothing', () async {
|
|
|
|
|
for (final authorization in [
|
|
|
|
|
null,
|
|
|
|
|
'',
|
|
|
|
|
'Bearer',
|
|
|
|
|
'Bearer ',
|
|
|
|
|
'Bearer not the secret',
|
|
|
|
|
'Bearer $_secret ',
|
|
|
|
|
'Basic $_secret',
|
|
|
|
|
_secret,
|
|
|
|
|
]) {
|
|
|
|
|
final answer = await ask(RegisterOperation.deleteAccount, {
|
|
|
|
|
'person': ben.uuid,
|
|
|
|
|
}, authorization: authorization);
|
|
|
|
|
expect(answer.status, 401, reason: authorization);
|
|
|
|
|
}
|
|
|
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'every call on a register without a secret, whatever is sent',
|
|
|
|
|
() async {
|
|
|
|
|
useInstanceSecret(pod, null);
|
|
|
|
|
|
|
|
|
|
for (final authorization in [null, 'Bearer ', 'Bearer null']) {
|
|
|
|
|
final answer = await ask(RegisterOperation.deleteAccount, {
|
|
|
|
|
'person': ben.uuid,
|
|
|
|
|
}, authorization: authorization);
|
|
|
|
|
expect(answer.status, 401, reason: authorization);
|
|
|
|
|
}
|
|
|
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('register calls on a member, which holds no register', () async {
|
|
|
|
|
useBase(
|
|
|
|
|
sessionBuilder,
|
|
|
|
|
accounts: MemberOfRegister(Uri.parse('http://learn.test:8082')),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final answer = await ask(RegisterOperation.knownAccounts, {
|
|
|
|
|
'people': [ben.uuid],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(answer.status, 404);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('what it cannot read, and operations it does not know', () async {
|
|
|
|
|
for (final body in ['', 'not JSON', '[]', '"ben"', 'null']) {
|
|
|
|
|
final answer = await answerMember(
|
|
|
|
|
session,
|
|
|
|
|
RegisterOperation.knownAccounts,
|
|
|
|
|
body,
|
|
|
|
|
authorization: 'Bearer $_secret',
|
|
|
|
|
);
|
|
|
|
|
expect(answer.status, 400, reason: body);
|
|
|
|
|
}
|
|
|
|
|
for (final arguments in <Map<String, Object?>>[
|
|
|
|
|
{},
|
|
|
|
|
{'people': 'not a list'},
|
|
|
|
|
{
|
|
|
|
|
'people': [1, 2],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'people': ['not an id'],
|
|
|
|
|
},
|
|
|
|
|
]) {
|
|
|
|
|
final answer = await ask(
|
|
|
|
|
RegisterOperation.knownAccounts,
|
|
|
|
|
arguments,
|
|
|
|
|
);
|
|
|
|
|
expect(answer.status, 400, reason: '$arguments');
|
|
|
|
|
}
|
|
|
|
|
expect(
|
|
|
|
|
(await ask(RegisterOperation.deleteAccount, {
|
|
|
|
|
'person': 'not an id',
|
|
|
|
|
})).status,
|
|
|
|
|
400,
|
|
|
|
|
);
|
|
|
|
|
expect((await ask('drop-everything', {})).status, 404);
|
|
|
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('profiles', () {
|
|
|
|
|
test('are saved, read and fixed for a member as for this server, '
|
|
|
|
|
'the same handle never twice', () async {
|
|
|
|
|
final anna = const Uuid().v7obj();
|
|
|
|
|
|
|
|
|
|
final saved = await remote.saveProfile(
|
|
|
|
|
session,
|
|
|
|
|
anna,
|
|
|
|
|
handle: 'Anna',
|
|
|
|
|
displayName: ' Anna ',
|
|
|
|
|
bio: 'Learning Greek.',
|
|
|
|
|
links: ['https://anna.example.org'],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(saved.handle, 'Anna');
|
|
|
|
|
expect(saved.displayName, 'Anna');
|
|
|
|
|
expect(
|
|
|
|
|
(await remote.profileOf(session, anna))!.toJson(),
|
|
|
|
|
saved.toJson(),
|
|
|
|
|
);
|
|
|
|
|
expect((await profileOf(session, anna))!.toJson(), saved.toJson());
|
|
|
|
|
expect(
|
|
|
|
|
(await remote.profileByHandle(session, 'anna'))!.toJson(),
|
|
|
|
|
saved.toJson(),
|
|
|
|
|
);
|
|
|
|
|
expect(await remote.personWithHandle(session, 'ANNA'), anna);
|
|
|
|
|
expect((await remote.profilesOf(session, {anna, ben})).keys, [anna]);
|
|
|
|
|
expect(
|
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.
So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.
Halkyon Learn's handle dialog words the new refusal (English, German).
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:39:14 +02:00
|
|
|
await remote.checkHandle(
|
|
|
|
|
session,
|
|
|
|
|
anna,
|
|
|
|
|
'Anna',
|
|
|
|
|
network: '203.0.113.7',
|
|
|
|
|
),
|
|
|
|
|
isNull,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
await remote.checkHandle(
|
|
|
|
|
session,
|
|
|
|
|
ben,
|
|
|
|
|
'anna',
|
|
|
|
|
network: '203.0.113.7',
|
|
|
|
|
),
|
2026-10-19 23:10:08 +02:00
|
|
|
ProfileRefusal.handleTaken,
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.saveProfile(
|
|
|
|
|
session,
|
|
|
|
|
ben,
|
|
|
|
|
handle: 'anna',
|
|
|
|
|
displayName: '',
|
|
|
|
|
bio: '',
|
|
|
|
|
links: const [],
|
|
|
|
|
),
|
|
|
|
|
refused(ProfileRefusal.handleTaken),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await remote.fixHandle(session, anna);
|
|
|
|
|
|
|
|
|
|
expect((await remote.profileOf(session, anna))!.handleFixed, isTrue);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.removeProfile(session, anna),
|
|
|
|
|
refused(ProfileRefusal.published),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.
So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.
Halkyon Learn's handle dialog words the new refusal (English, German).
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:39:14 +02:00
|
|
|
test("not public yet are nobody's to a member's apps, while its code "
|
|
|
|
|
'still finds whose a handle is (D5)', () async {
|
|
|
|
|
final anna = const Uuid().v7obj();
|
|
|
|
|
await remote.saveProfile(
|
|
|
|
|
session,
|
|
|
|
|
anna,
|
|
|
|
|
handle: 'Anna',
|
|
|
|
|
displayName: '',
|
|
|
|
|
bio: '',
|
|
|
|
|
links: const [],
|
|
|
|
|
network: '198.51.100.7',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(await remote.profileByHandle(session, 'anna'), isNull);
|
|
|
|
|
expect(await remote.personWithHandle(session, 'anna'), anna);
|
|
|
|
|
expect((await remote.profileOf(session, anna))!.handle, 'Anna');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("count a member's answers about handles on the register, by "
|
|
|
|
|
"the person and the visitor's network", () async {
|
|
|
|
|
final anna = const Uuid().v7obj();
|
|
|
|
|
await remote.saveProfile(
|
|
|
|
|
session,
|
|
|
|
|
anna,
|
|
|
|
|
handle: 'Anna',
|
|
|
|
|
displayName: '',
|
|
|
|
|
bio: '',
|
|
|
|
|
links: const [],
|
|
|
|
|
network: '198.51.100.7',
|
|
|
|
|
);
|
|
|
|
|
await remote.checkHandle(
|
|
|
|
|
session,
|
|
|
|
|
anna,
|
|
|
|
|
'Anna_G',
|
|
|
|
|
network: '198.51.100.7',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final counted = await RateLimitedRequestAttempt.db.find(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.domain.equals('profile'),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
counted.map((note) => (note.source, note.key)).toSet(),
|
|
|
|
|
{
|
|
|
|
|
('handle_checks', anna.uuid),
|
|
|
|
|
('handle_checks_per_network', '198.51.100.7'),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
expect(counted, hasLength(4));
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
test('missing are said to be so', () async {
|
|
|
|
|
final nobody = const Uuid().v7obj();
|
|
|
|
|
|
|
|
|
|
expect(await remote.profileOf(session, nobody), isNull);
|
|
|
|
|
expect(await remote.profileByHandle(session, 'nobody'), isNull);
|
|
|
|
|
expect(await remote.personWithHandle(session, 'nobody'), isNull);
|
|
|
|
|
expect(await remote.profilesOf(session, {nobody}), isEmpty);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.removeAvatar(session, nobody),
|
|
|
|
|
throwsA(isA<ProfileNotFoundException>()),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.checkLinks(session, nobody),
|
|
|
|
|
throwsA(isA<ProfileNotFoundException>()),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('email sign-in', () {
|
|
|
|
|
test('is offered as the register offers it', () async {
|
|
|
|
|
expect(await remote.emailSignInOffered(session), isTrue);
|
|
|
|
|
EmailCodes.of(pod).mailer = null;
|
|
|
|
|
expect(await remote.emailSignInOffered(session), isFalse);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("counts the codes a member asks for by its device's network, "
|
|
|
|
|
"not the member's", () async {
|
|
|
|
|
for (var i = 0; i < 10; i++) {
|
|
|
|
|
await remote.sendSignInCode(
|
|
|
|
|
session,
|
|
|
|
|
address: 'person$i@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
network: '203.0.113.7',
|
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
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.sendSignInCode(
|
|
|
|
|
session,
|
|
|
|
|
address: 'person10@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
network: '203.0.113.7',
|
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
|
|
|
),
|
|
|
|
|
refused(EmailSignInRefusal.tooManyCodes),
|
|
|
|
|
);
|
|
|
|
|
await remote.sendSignInCode(
|
|
|
|
|
session,
|
|
|
|
|
address: 'person10@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
network: '198.51.100.4',
|
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(mailbox.sent, hasLength(11));
|
|
|
|
|
});
|
|
|
|
|
|
feat(auth)!: codes by mail no longer sign in
ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.
The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.
BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 19:31:49 +02:00
|
|
|
test("a code proves the address for a member's password, and a "
|
|
|
|
|
'wrong one refuses it, keeping nothing', () async {
|
|
|
|
|
usePasswordKeys(pod, testPasswordKeys);
|
|
|
|
|
addTearDown(() => usePasswordKeys(pod, null));
|
|
|
|
|
final device = await PasswordDevice.make();
|
2026-10-19 23:10:08 +02:00
|
|
|
final annaOnTodo = const Uuid().v7obj();
|
|
|
|
|
final request = await remote.sendSignInCode(
|
|
|
|
|
session,
|
|
|
|
|
address: 'Anna@example.org',
|
|
|
|
|
language: 'de',
|
|
|
|
|
network: '203.0.113.7',
|
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
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
feat(auth)!: codes by mail no longer sign in
ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.
The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.
BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 19:31:49 +02:00
|
|
|
remote.setPassword(
|
2026-10-19 23:10:08 +02:00
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
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
|
|
|
anonymousHere: true,
|
feat(auth)!: codes by mail no longer sign in
ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.
The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.
BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 19:31:49 +02:00
|
|
|
setup: device.setup(),
|
2026-10-19 23:10:08 +02:00
|
|
|
request: request,
|
|
|
|
|
code: 'AAAA-AAAA',
|
|
|
|
|
),
|
|
|
|
|
refused(EmailSignInRefusal.wrongCode),
|
|
|
|
|
);
|
feat(auth)!: codes by mail no longer sign in
ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.
The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.
BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 19:31:49 +02:00
|
|
|
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
|
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
|
|
|
// Without the member saying Anna is anonymous there, refused
|
|
|
|
|
// before her code is used (the full reading's M1).
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.setPassword(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
setup: device.setup(),
|
|
|
|
|
request: request,
|
|
|
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
|
|
|
),
|
|
|
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
|
|
|
);
|
feat(auth)!: codes by mail no longer sign in
ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.
The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.
BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 19:31:49 +02:00
|
|
|
await remote.setPassword(
|
2026-10-19 23:10:08 +02:00
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
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
|
|
|
anonymousHere: true,
|
feat(auth)!: codes by mail no longer sign in
ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.
The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.
BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 19:31:49 +02:00
|
|
|
setup: device.setup(),
|
2026-10-19 23:10:08 +02:00
|
|
|
request: request,
|
feat(auth)!: codes by mail no longer sign in
ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.
The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.
BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 19:31:49 +02:00
|
|
|
code: mailbox.codeFor('anna@example.org'),
|
2026-10-19 23:10:08 +02:00
|
|
|
);
|
|
|
|
|
expect(
|
feat(auth)!: codes by mail no longer sign in
ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.
The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.
BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 19:31:49 +02:00
|
|
|
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
|
|
|
|
|
contains(identified.name),
|
2026-10-19 23:10:08 +02:00
|
|
|
);
|
feat(auth)!: codes by mail no longer sign in
ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.
The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.
BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 19:31:49 +02:00
|
|
|
expect(await remote.knownAccounts(session, {annaOnTodo, ben}), {
|
|
|
|
|
annaOnTodo,
|
|
|
|
|
ben,
|
|
|
|
|
});
|
2026-10-19 23:10:08 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('GitHub', () {
|
|
|
|
|
test("links a member's person, who becomes an account under the "
|
|
|
|
|
'same ID, found by it afterwards', () async {
|
|
|
|
|
final annaOnTodo = const Uuid().v7obj();
|
|
|
|
|
|
|
|
|
|
await remote.linkGitHub(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
gitHubUserId: '2002',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(await remote.accountWithGitHub(session, '2002'), annaOnTodo);
|
|
|
|
|
expect(await remote.accountWithGitHub(session, '1001'), ben);
|
|
|
|
|
expect(await remote.accountWithGitHub(session, '3003'), isNull);
|
|
|
|
|
expect(
|
|
|
|
|
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
|
|
|
|
|
contains(identified.name),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
"refuses another person's GitHub account, keeping nothing",
|
|
|
|
|
() async {
|
|
|
|
|
final annaOnTodo = const Uuid().v7obj();
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.linkGitHub(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
gitHubUserId: '1001',
|
|
|
|
|
),
|
|
|
|
|
refused(GitHubSignInRefusal.takenByAnotherPerson),
|
|
|
|
|
);
|
|
|
|
|
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)
The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.
The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:03:15 +02:00
|
|
|
group('SimpleLogin (ADR 0020)', () {
|
|
|
|
|
late TestKey key;
|
|
|
|
|
late FakeProvider simpleLogin;
|
|
|
|
|
|
|
|
|
|
setUpAll(() => key = TestKey.generate('sl', seed: 12));
|
|
|
|
|
setUp(() {
|
|
|
|
|
simpleLogin = FakeProvider.simpleLogin(
|
|
|
|
|
key: key,
|
|
|
|
|
clientId: 'halkyon-learn',
|
|
|
|
|
clientSecret: 'a secret of the app',
|
|
|
|
|
callbackUrl: Uri.parse('https://learn.test/simplelogin-callback'),
|
|
|
|
|
);
|
|
|
|
|
useSimpleLogin(sessionBuilder, simpleLogin);
|
|
|
|
|
});
|
|
|
|
|
tearDown(() => useSimpleLoginApp(pod, null));
|
|
|
|
|
|
|
|
|
|
test('is offered as the register offers it', () async {
|
|
|
|
|
expect(await remote.simpleLoginOffered(session), isTrue);
|
|
|
|
|
useSimpleLogin(sessionBuilder, null);
|
|
|
|
|
expect(await remote.simpleLoginOffered(session), isFalse);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("begins for a member's app, signed in or out, and signs in "
|
|
|
|
|
'the account with that identity', () async {
|
|
|
|
|
await OidcIdentity.db.insertRow(
|
|
|
|
|
session,
|
|
|
|
|
OidcIdentity(
|
|
|
|
|
authUserId: ben,
|
|
|
|
|
provider: simpleLoginProvider,
|
|
|
|
|
subject: 'sl-ben',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final start = await remote.beginSimpleLogin(
|
|
|
|
|
session,
|
2026-10-23 23:35:46 +02:00
|
|
|
marker: 'todo-loopback',
|
feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)
The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.
The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:03:15 +02:00
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
);
|
|
|
|
|
final proof = await remote.completeSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
code: simpleLogin.authorize(
|
|
|
|
|
start.authorizationUrl,
|
|
|
|
|
subject: 'sl-ben',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-23 23:35:46 +02:00
|
|
|
expect(start.state, startsWith('todo-loopback.'));
|
feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)
The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.
The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:03:15 +02:00
|
|
|
expect(proof.outcome, SimpleLoginOutcome.signedIn);
|
|
|
|
|
expect(proof.account, ben);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("adds SimpleLogin to a member's person, who becomes an account "
|
|
|
|
|
'under the same ID', () async {
|
|
|
|
|
final annaOnTodo = const Uuid().v7obj();
|
|
|
|
|
|
|
|
|
|
final start = await remote.beginSimpleLogin(
|
|
|
|
|
session,
|
2026-10-23 23:35:46 +02:00
|
|
|
marker: 'todo-scheme',
|
feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)
The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.
The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:03:15 +02:00
|
|
|
caller: annaOnTodo,
|
|
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
);
|
|
|
|
|
final proof = await remote.completeSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
code: simpleLogin.authorize(
|
|
|
|
|
start.authorizationUrl,
|
|
|
|
|
subject: 'sl-anna',
|
|
|
|
|
),
|
|
|
|
|
caller: annaOnTodo,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(proof.outcome, SimpleLoginOutcome.linked);
|
|
|
|
|
expect(proof.account, annaOnTodo);
|
|
|
|
|
expect(
|
|
|
|
|
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
|
|
|
|
|
contains(identified.name),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
(await remote.signInWaysOf(session, annaOnTodo)).simpleLogin,
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('passes on the handle suggested to someone new', () async {
|
|
|
|
|
final start = await remote.beginSimpleLogin(
|
|
|
|
|
session,
|
2026-10-23 23:35:46 +02:00
|
|
|
marker: 'todo-loopback',
|
feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)
The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.
The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:03:15 +02:00
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
);
|
|
|
|
|
final proof = await remote.completeSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
code: simpleLogin.authorize(
|
|
|
|
|
start.authorizationUrl,
|
|
|
|
|
subject: 'sl-carla',
|
|
|
|
|
claims: {'name': 'Carla Müller'},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(proof.outcome, SimpleLoginOutcome.newcomer);
|
|
|
|
|
expect(proof.account, isNull);
|
|
|
|
|
expect(proof.suggestedHandle, 'carla_mueller');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("refuses as the register refuses, halkyon_oidc's refusals "
|
|
|
|
|
'crossing whole', () async {
|
|
|
|
|
final start = await remote.beginSimpleLogin(
|
|
|
|
|
session,
|
2026-10-23 23:35:46 +02:00
|
|
|
marker: 'todo-loopback',
|
feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)
The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.
The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:03:15 +02:00
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
);
|
|
|
|
|
final code = simpleLogin.authorize(
|
|
|
|
|
start.authorizationUrl,
|
|
|
|
|
subject: 'sl-carla',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.completeSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
code: code,
|
|
|
|
|
caller: const Uuid().v7obj(),
|
|
|
|
|
),
|
|
|
|
|
throwsA(
|
|
|
|
|
isA<OidcRefusedException>().having(
|
|
|
|
|
(error) => error.reason,
|
|
|
|
|
'reason',
|
|
|
|
|
OidcRefusal.wrongCaller,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.completeSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: const Uuid().v7obj(),
|
|
|
|
|
code: code,
|
|
|
|
|
),
|
|
|
|
|
refused(OidcRefusal.unknownAttempt),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3)
An account that signs in with SimpleLogin keeps its keys under a password
that signs nobody in (D19): `account_password` keeps the password's own
proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which
becomes nullable. The migration adds the column and drops the NOT NULL,
nothing else.
- `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity
waits under the attempt gets, in one transaction, an account
(identified), the link, a profile with the handle and the keys; then a
session (D18). Refused, the identity still waiting, for a setup out of
range and a handle refused as saving a profile refuses it.
- `simpleLogin.checkHandle(attempt, handle)`: whether someone new could
take a handle, at most 30 an hour per attempt and 60 per network.
- `simpleLogin.complete` says, for an account, whether it has its keys and
its handle, and keeps the sign-in ten minutes under its attempt, the
account as its caller: D20's confirmation for the changes to come.
- `keys.begin`, `keys.unlock`: the keys for a signed-in device once the
password is proven with its proof key, for a nonce, bound to the base
URL the device addressed; wrong proofs counted per session as ADR
0017's.
- An authenticator is enrolled only for a password that signs in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the four new operations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
|
|
|
test("signs up someone new for a member's app, the handle checked "
|
|
|
|
|
'first, whose keys then unlock with the password', () async {
|
|
|
|
|
final device = await KeysDevice.make();
|
|
|
|
|
final start = await remote.beginSimpleLogin(
|
|
|
|
|
session,
|
2026-10-23 23:35:46 +02:00
|
|
|
marker: 'todo-loopback',
|
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3)
An account that signs in with SimpleLogin keeps its keys under a password
that signs nobody in (D19): `account_password` keeps the password's own
proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which
becomes nullable. The migration adds the column and drops the NOT NULL,
nothing else.
- `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity
waits under the attempt gets, in one transaction, an account
(identified), the link, a profile with the handle and the keys; then a
session (D18). Refused, the identity still waiting, for a setup out of
range and a handle refused as saving a profile refuses it.
- `simpleLogin.checkHandle(attempt, handle)`: whether someone new could
take a handle, at most 30 an hour per attempt and 60 per network.
- `simpleLogin.complete` says, for an account, whether it has its keys and
its handle, and keeps the sign-in ten minutes under its attempt, the
account as its caller: D20's confirmation for the changes to come.
- `keys.begin`, `keys.unlock`: the keys for a signed-in device once the
password is proven with its proof key, for a nonce, bound to the base
URL the device addressed; wrong proofs counted per session as ADR
0017's.
- An authenticator is enrolled only for a password that signs in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the four new operations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
);
|
|
|
|
|
await remote.completeSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
code: simpleLogin.authorize(
|
|
|
|
|
start.authorizationUrl,
|
|
|
|
|
subject: 'sl-carla',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
await remote.checkSimpleLoginHandle(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
handle: 'carla',
|
|
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
),
|
|
|
|
|
isNull,
|
|
|
|
|
);
|
|
|
|
|
final carla = await remote.signUpWithSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
handle: 'carla',
|
|
|
|
|
setup: device.setup(),
|
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.
So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.
Halkyon Learn's handle dialog words the new refusal (English, German).
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:39:14 +02:00
|
|
|
network: '203.0.113.0/24',
|
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3)
An account that signs in with SimpleLogin keeps its keys under a password
that signs nobody in (D19): `account_password` keeps the password's own
proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which
becomes nullable. The migration adds the column and drops the NOT NULL,
nothing else.
- `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity
waits under the attempt gets, in one transaction, an account
(identified), the link, a profile with the handle and the keys; then a
session (D18). Refused, the identity still waiting, for a setup out of
range and a handle refused as saving a profile refuses it.
- `simpleLogin.checkHandle(attempt, handle)`: whether someone new could
take a handle, at most 30 an hour per attempt and 60 per network.
- `simpleLogin.complete` says, for an account, whether it has its keys and
its handle, and keeps the sign-in ten minutes under its attempt, the
account as its caller: D20's confirmation for the changes to come.
- `keys.begin`, `keys.unlock`: the keys for a signed-in device once the
password is proven with its proof key, for a nonce, bound to the base
URL the device addressed; wrong proofs counted per session as ADR
0017's.
- An authenticator is enrolled only for a password that signs in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the four new operations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
(await AuthUser.db.findById(session, carla))!.scopeNames,
|
|
|
|
|
{identified.name},
|
|
|
|
|
);
|
|
|
|
|
final ways = await remote.signInWaysOf(session, carla);
|
|
|
|
|
expect(
|
|
|
|
|
(ways.simpleLogin, ways.keys, ways.password),
|
|
|
|
|
(
|
|
|
|
|
true,
|
|
|
|
|
true,
|
|
|
|
|
false,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
final keys = await remote.unlockKeys(
|
|
|
|
|
session,
|
|
|
|
|
carla,
|
|
|
|
|
await device.prove(
|
|
|
|
|
await remote.beginPasswordKeyProof(session, carla),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
Uint8List.sublistView(keys.signingPublicKey),
|
|
|
|
|
device.signingPublicKey,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('refuses a sign-up and keys as the register refuses, a '
|
|
|
|
|
"handle's refusals crossing whole", () async {
|
|
|
|
|
final device = await KeysDevice.make();
|
|
|
|
|
final start = await remote.beginSimpleLogin(
|
|
|
|
|
session,
|
2026-10-23 23:35:46 +02:00
|
|
|
marker: 'todo-loopback',
|
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3)
An account that signs in with SimpleLogin keeps its keys under a password
that signs nobody in (D19): `account_password` keeps the password's own
proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which
becomes nullable. The migration adds the column and drops the NOT NULL,
nothing else.
- `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity
waits under the attempt gets, in one transaction, an account
(identified), the link, a profile with the handle and the keys; then a
session (D18). Refused, the identity still waiting, for a setup out of
range and a handle refused as saving a profile refuses it.
- `simpleLogin.checkHandle(attempt, handle)`: whether someone new could
take a handle, at most 30 an hour per attempt and 60 per network.
- `simpleLogin.complete` says, for an account, whether it has its keys and
its handle, and keeps the sign-in ten minutes under its attempt, the
account as its caller: D20's confirmation for the changes to come.
- `keys.begin`, `keys.unlock`: the keys for a signed-in device once the
password is proven with its proof key, for a nonce, bound to the base
URL the device addressed; wrong proofs counted per session as ADR
0017's.
- An authenticator is enrolled only for a password that signs in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the four new operations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
);
|
|
|
|
|
await remote.completeSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
code: simpleLogin.authorize(
|
|
|
|
|
start.authorizationUrl,
|
|
|
|
|
subject: 'sl-carla',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
await remote.checkSimpleLoginHandle(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
handle: 'admin',
|
|
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
),
|
|
|
|
|
ProfileRefusal.handleReserved,
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.signUpWithSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
handle: 'admin',
|
|
|
|
|
setup: device.setup(),
|
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.
So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.
Halkyon Learn's handle dialog words the new refusal (English, German).
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:39:14 +02:00
|
|
|
network: '203.0.113.0/24',
|
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3)
An account that signs in with SimpleLogin keeps its keys under a password
that signs nobody in (D19): `account_password` keeps the password's own
proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which
becomes nullable. The migration adds the column and drops the NOT NULL,
nothing else.
- `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity
waits under the attempt gets, in one transaction, an account
(identified), the link, a profile with the handle and the keys; then a
session (D18). Refused, the identity still waiting, for a setup out of
range and a handle refused as saving a profile refuses it.
- `simpleLogin.checkHandle(attempt, handle)`: whether someone new could
take a handle, at most 30 an hour per attempt and 60 per network.
- `simpleLogin.complete` says, for an account, whether it has its keys and
its handle, and keeps the sign-in ten minutes under its attempt, the
account as its caller: D20's confirmation for the changes to come.
- `keys.begin`, `keys.unlock`: the keys for a signed-in device once the
password is proven with its proof key, for a nonce, bound to the base
URL the device addressed; wrong proofs counted per session as ADR
0017's.
- An authenticator is enrolled only for a password that signs in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the four new operations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
|
|
|
),
|
|
|
|
|
refused(ProfileRefusal.handleReserved),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.signUpWithSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: const Uuid().v7obj(),
|
|
|
|
|
handle: 'carla',
|
|
|
|
|
setup: device.setup(),
|
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.
So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.
Halkyon Learn's handle dialog words the new refusal (English, German).
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:39:14 +02:00
|
|
|
network: '203.0.113.0/24',
|
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3)
An account that signs in with SimpleLogin keeps its keys under a password
that signs nobody in (D19): `account_password` keeps the password's own
proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which
becomes nullable. The migration adds the column and drops the NOT NULL,
nothing else.
- `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity
waits under the attempt gets, in one transaction, an account
(identified), the link, a profile with the handle and the keys; then a
session (D18). Refused, the identity still waiting, for a setup out of
range and a handle refused as saving a profile refuses it.
- `simpleLogin.checkHandle(attempt, handle)`: whether someone new could
take a handle, at most 30 an hour per attempt and 60 per network.
- `simpleLogin.complete` says, for an account, whether it has its keys and
its handle, and keeps the sign-in ten minutes under its attempt, the
account as its caller: D20's confirmation for the changes to come.
- `keys.begin`, `keys.unlock`: the keys for a signed-in device once the
password is proven with its proof key, for a nonce, bound to the base
URL the device addressed; wrong proofs counted per session as ADR
0017's.
- An authenticator is enrolled only for a password that signs in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the four new operations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
|
|
|
),
|
|
|
|
|
refused(OidcRefusal.unknownAttempt),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.beginPasswordKeyProof(session, ben),
|
|
|
|
|
refused(PasswordRefusal.noPassword),
|
|
|
|
|
);
|
|
|
|
|
final carla = await remote.signUpWithSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
handle: 'carla',
|
|
|
|
|
setup: device.setup(),
|
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.
So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.
Halkyon Learn's handle dialog words the new refusal (English, German).
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:39:14 +02:00
|
|
|
network: '203.0.113.0/24',
|
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3)
An account that signs in with SimpleLogin keeps its keys under a password
that signs nobody in (D19): `account_password` keeps the password's own
proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which
becomes nullable. The migration adds the column and drops the NOT NULL,
nothing else.
- `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity
waits under the attempt gets, in one transaction, an account
(identified), the link, a profile with the handle and the keys; then a
session (D18). Refused, the identity still waiting, for a setup out of
range and a handle refused as saving a profile refuses it.
- `simpleLogin.checkHandle(attempt, handle)`: whether someone new could
take a handle, at most 30 an hour per attempt and 60 per network.
- `simpleLogin.complete` says, for an account, whether it has its keys and
its handle, and keeps the sign-in ten minutes under its attempt, the
account as its caller: D20's confirmation for the changes to come.
- `keys.begin`, `keys.unlock`: the keys for a signed-in device once the
password is proven with its proof key, for a nonce, bound to the base
URL the device addressed; wrong proofs counted per session as ADR
0017's.
- An authenticator is enrolled only for a password that signs in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the four new operations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.unlockKeys(
|
|
|
|
|
session,
|
|
|
|
|
carla,
|
|
|
|
|
await (await KeysDevice.make(seed: 5)).prove(
|
|
|
|
|
await remote.beginPasswordKeyProof(session, carla),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20)
Every change takes the password's proof by its own proof key (the
recovery phrase's for a lost password) and a SimpleLogin sign-in of the
last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR
0017's take the password and a code. The sign-in is checked before the
proof and taken with the change, so a wrong proof leaves it standing and
each sign-in serves one change.
- `keys.set(attempt, setup, handle?)`: the first keys of an account that
SimpleLogin was added to (anonymous before, or signing in with GitHub
or a code), with the handle where it has none; the sign-in alone, as
there is no password to prove yet.
- `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a
lost password, with the phrase): every other session ends, in every
service, as ADR 0017's change has it.
- `keys.setRecoveryKeys`: a new phrase.
- `account.delete(keysProof)`: an account without SRP's verifier deletes
itself with the proof and the sign-in; `requireOwnerToDelete` no longer
asks such an account for SRP.
- `newKeysPasswordDigest` on both sides, held to each other by a test; a
challenge purpose of its own for the phrase's proof while signed in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the five new operations and the deletion's proof.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
|
|
|
/// A SimpleLogin sign-in of [person]'s through a member, as
|
|
|
|
|
/// [subject], just now: its attempt, which stands for it ten
|
|
|
|
|
/// minutes (D20).
|
|
|
|
|
Future<UuidValue> signedInAgain(
|
|
|
|
|
UuidValue person,
|
|
|
|
|
String subject,
|
|
|
|
|
) async {
|
|
|
|
|
final start = await remote.beginSimpleLogin(
|
|
|
|
|
session,
|
2026-10-23 23:35:46 +02:00
|
|
|
marker: 'todo-loopback',
|
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20)
Every change takes the password's proof by its own proof key (the
recovery phrase's for a lost password) and a SimpleLogin sign-in of the
last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR
0017's take the password and a code. The sign-in is checked before the
proof and taken with the change, so a wrong proof leaves it standing and
each sign-in serves one change.
- `keys.set(attempt, setup, handle?)`: the first keys of an account that
SimpleLogin was added to (anonymous before, or signing in with GitHub
or a code), with the handle where it has none; the sign-in alone, as
there is no password to prove yet.
- `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a
lost password, with the phrase): every other session ends, in every
service, as ADR 0017's change has it.
- `keys.setRecoveryKeys`: a new phrase.
- `account.delete(keysProof)`: an account without SRP's verifier deletes
itself with the proof and the sign-in; `requireOwnerToDelete` no longer
asks such an account for SRP.
- `newKeysPasswordDigest` on both sides, held to each other by a test; a
challenge purpose of its own for the phrase's proof while signed in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the five new operations and the deletion's proof.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
|
|
|
caller: person,
|
|
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
);
|
|
|
|
|
await remote.completeSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
code: simpleLogin.authorize(
|
|
|
|
|
start.authorizationUrl,
|
|
|
|
|
subject: subject,
|
|
|
|
|
),
|
|
|
|
|
caller: person,
|
|
|
|
|
);
|
|
|
|
|
return start.attempt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
"gives a member's account its first keys and handle, once "
|
|
|
|
|
'SimpleLogin was added to it, its refusals crossing whole',
|
|
|
|
|
() async {
|
|
|
|
|
final device = await KeysDevice.make();
|
|
|
|
|
final attempt = await signedInAgain(ben, 'sl-ben');
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.setFirstKeys(
|
|
|
|
|
session,
|
|
|
|
|
person: ben,
|
|
|
|
|
attempt: attempt,
|
|
|
|
|
setup: device.setup(),
|
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.
So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.
Halkyon Learn's handle dialog words the new refusal (English, German).
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:39:14 +02:00
|
|
|
network: '203.0.113.0/24',
|
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20)
Every change takes the password's proof by its own proof key (the
recovery phrase's for a lost password) and a SimpleLogin sign-in of the
last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR
0017's take the password and a code. The sign-in is checked before the
proof and taken with the change, so a wrong proof leaves it standing and
each sign-in serves one change.
- `keys.set(attempt, setup, handle?)`: the first keys of an account that
SimpleLogin was added to (anonymous before, or signing in with GitHub
or a code), with the handle where it has none; the sign-in alone, as
there is no password to prove yet.
- `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a
lost password, with the phrase): every other session ends, in every
service, as ADR 0017's change has it.
- `keys.setRecoveryKeys`: a new phrase.
- `account.delete(keysProof)`: an account without SRP's verifier deletes
itself with the proof and the sign-in; `requireOwnerToDelete` no longer
asks such an account for SRP.
- `newKeysPasswordDigest` on both sides, held to each other by a test; a
challenge purpose of its own for the phrase's proof while signed in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the five new operations and the deletion's proof.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
|
|
|
),
|
|
|
|
|
refused(ProfileRefusal.handleInvalid),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.setFirstKeys(
|
|
|
|
|
session,
|
|
|
|
|
person: ben,
|
|
|
|
|
attempt: const Uuid().v7obj(),
|
|
|
|
|
setup: device.setup(),
|
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.
So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.
Halkyon Learn's handle dialog words the new refusal (English, German).
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:39:14 +02:00
|
|
|
network: '203.0.113.0/24',
|
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20)
Every change takes the password's proof by its own proof key (the
recovery phrase's for a lost password) and a SimpleLogin sign-in of the
last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR
0017's take the password and a code. The sign-in is checked before the
proof and taken with the change, so a wrong proof leaves it standing and
each sign-in serves one change.
- `keys.set(attempt, setup, handle?)`: the first keys of an account that
SimpleLogin was added to (anonymous before, or signing in with GitHub
or a code), with the handle where it has none; the sign-in alone, as
there is no password to prove yet.
- `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a
lost password, with the phrase): every other session ends, in every
service, as ADR 0017's change has it.
- `keys.setRecoveryKeys`: a new phrase.
- `account.delete(keysProof)`: an account without SRP's verifier deletes
itself with the proof and the sign-in; `requireOwnerToDelete` no longer
asks such an account for SRP.
- `newKeysPasswordDigest` on both sides, held to each other by a test; a
challenge purpose of its own for the phrase's proof while signed in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the five new operations and the deletion's proof.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
|
|
|
handle: 'ben',
|
|
|
|
|
),
|
|
|
|
|
refused(OidcRefusal.unknownAttempt),
|
|
|
|
|
);
|
|
|
|
|
await remote.setFirstKeys(
|
|
|
|
|
session,
|
|
|
|
|
person: ben,
|
|
|
|
|
attempt: attempt,
|
|
|
|
|
setup: device.setup(),
|
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.
So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.
Halkyon Learn's handle dialog words the new refusal (English, German).
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:39:14 +02:00
|
|
|
network: '203.0.113.0/24',
|
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20)
Every change takes the password's proof by its own proof key (the
recovery phrase's for a lost password) and a SimpleLogin sign-in of the
last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR
0017's take the password and a code. The sign-in is checked before the
proof and taken with the change, so a wrong proof leaves it standing and
each sign-in serves one change.
- `keys.set(attempt, setup, handle?)`: the first keys of an account that
SimpleLogin was added to (anonymous before, or signing in with GitHub
or a code), with the handle where it has none; the sign-in alone, as
there is no password to prove yet.
- `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a
lost password, with the phrase): every other session ends, in every
service, as ADR 0017's change has it.
- `keys.setRecoveryKeys`: a new phrase.
- `account.delete(keysProof)`: an account without SRP's verifier deletes
itself with the proof and the sign-in; `requireOwnerToDelete` no longer
asks such an account for SRP.
- `newKeysPasswordDigest` on both sides, held to each other by a test; a
challenge purpose of its own for the phrase's proof while signed in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the five new operations and the deletion's proof.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
|
|
|
handle: 'ben',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect((await remote.signInWaysOf(session, ben)).keys, isTrue);
|
|
|
|
|
expect((await remote.profileOf(session, ben))!.handle, 'ben');
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.setFirstKeys(
|
|
|
|
|
session,
|
|
|
|
|
person: ben,
|
|
|
|
|
attempt: await signedInAgain(ben, 'sl-ben'),
|
|
|
|
|
setup: device.setup(),
|
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.
So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.
Halkyon Learn's handle dialog words the new refusal (English, German).
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:39:14 +02:00
|
|
|
network: '203.0.113.0/24',
|
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20)
Every change takes the password's proof by its own proof key (the
recovery phrase's for a lost password) and a SimpleLogin sign-in of the
last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR
0017's take the password and a code. The sign-in is checked before the
proof and taken with the change, so a wrong proof leaves it standing and
each sign-in serves one change.
- `keys.set(attempt, setup, handle?)`: the first keys of an account that
SimpleLogin was added to (anonymous before, or signing in with GitHub
or a code), with the handle where it has none; the sign-in alone, as
there is no password to prove yet.
- `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a
lost password, with the phrase): every other session ends, in every
service, as ADR 0017's change has it.
- `keys.setRecoveryKeys`: a new phrase.
- `account.delete(keysProof)`: an account without SRP's verifier deletes
itself with the proof and the sign-in; `requireOwnerToDelete` no longer
asks such an account for SRP.
- `newKeysPasswordDigest` on both sides, held to each other by a test; a
challenge purpose of its own for the phrase's proof while signed in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the five new operations and the deletion's proof.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
|
|
|
),
|
|
|
|
|
refused(PasswordRefusal.passwordExists),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test("changes a member's account's keys, each change with the "
|
|
|
|
|
"password's proof (the phrase's for a lost one) and a sign-in, "
|
|
|
|
|
'and deletes it alike', () async {
|
|
|
|
|
final device = await KeysDevice.make();
|
|
|
|
|
final next = await KeysDevice.make(seed: 7);
|
|
|
|
|
final start = await remote.beginSimpleLogin(
|
|
|
|
|
session,
|
2026-10-23 23:35:46 +02:00
|
|
|
marker: 'todo-loopback',
|
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20)
Every change takes the password's proof by its own proof key (the
recovery phrase's for a lost password) and a SimpleLogin sign-in of the
last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR
0017's take the password and a code. The sign-in is checked before the
proof and taken with the change, so a wrong proof leaves it standing and
each sign-in serves one change.
- `keys.set(attempt, setup, handle?)`: the first keys of an account that
SimpleLogin was added to (anonymous before, or signing in with GitHub
or a code), with the handle where it has none; the sign-in alone, as
there is no password to prove yet.
- `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a
lost password, with the phrase): every other session ends, in every
service, as ADR 0017's change has it.
- `keys.setRecoveryKeys`: a new phrase.
- `account.delete(keysProof)`: an account without SRP's verifier deletes
itself with the proof and the sign-in; `requireOwnerToDelete` no longer
asks such an account for SRP.
- `newKeysPasswordDigest` on both sides, held to each other by a test; a
challenge purpose of its own for the phrase's proof while signed in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the five new operations and the deletion's proof.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
);
|
|
|
|
|
await remote.completeSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
code: simpleLogin.authorize(
|
|
|
|
|
start.authorizationUrl,
|
|
|
|
|
subject: 'sl-carla',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
final carla = await remote.signUpWithSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
handle: 'carla',
|
|
|
|
|
setup: device.setup(),
|
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.
So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.
Halkyon Learn's handle dialog words the new refusal (English, German).
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:39:14 +02:00
|
|
|
network: '203.0.113.0/24',
|
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20)
Every change takes the password's proof by its own proof key (the
recovery phrase's for a lost password) and a SimpleLogin sign-in of the
last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR
0017's take the password and a code. The sign-in is checked before the
proof and taken with the change, so a wrong proof leaves it standing and
each sign-in serves one change.
- `keys.set(attempt, setup, handle?)`: the first keys of an account that
SimpleLogin was added to (anonymous before, or signing in with GitHub
or a code), with the handle where it has none; the sign-in alone, as
there is no password to prove yet.
- `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a
lost password, with the phrase): every other session ends, in every
service, as ADR 0017's change has it.
- `keys.setRecoveryKeys`: a new phrase.
- `account.delete(keysProof)`: an account without SRP's verifier deletes
itself with the proof and the sign-in; `requireOwnerToDelete` no longer
asks such an account for SRP.
- `newKeysPasswordDigest` on both sides, held to each other by a test; a
challenge purpose of its own for the phrase's proof while signed in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the five new operations and the deletion's proof.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
|
|
|
);
|
|
|
|
|
final keepSession = const Uuid().v7obj();
|
|
|
|
|
|
|
|
|
|
await remote.changeKeysPassword(
|
|
|
|
|
session,
|
|
|
|
|
person: carla,
|
|
|
|
|
proof: await device.ownerProof(
|
|
|
|
|
await signedInAgain(carla, 'sl-carla'),
|
|
|
|
|
challenge: await remote.beginPasswordKeyProof(session, carla),
|
|
|
|
|
purpose: KeyProofPurpose.newPassword,
|
|
|
|
|
digest: newKeysPasswordDigest(next.password()),
|
|
|
|
|
),
|
|
|
|
|
password: next.password(),
|
|
|
|
|
keepSession: keepSession,
|
|
|
|
|
);
|
|
|
|
|
// Every member is told to end the account's other sessions.
|
|
|
|
|
await sessionNoticesDelivered();
|
|
|
|
|
expect(members.sessionsTold.single.$2, carla);
|
|
|
|
|
expect(members.sessionsTold.single.$4, keepSession);
|
|
|
|
|
await remote.unlockKeys(
|
|
|
|
|
session,
|
|
|
|
|
carla,
|
|
|
|
|
await next.prove(
|
|
|
|
|
await remote.beginPasswordKeyProof(session, carla),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await remote.resetKeysPassword(
|
|
|
|
|
session,
|
|
|
|
|
person: carla,
|
|
|
|
|
proof: await device.ownerProof(
|
|
|
|
|
await signedInAgain(carla, 'sl-carla'),
|
|
|
|
|
phrase: await remote.beginPhraseKeyProof(session, carla),
|
|
|
|
|
purpose: KeyProofPurpose.passwordReset,
|
|
|
|
|
digest: newKeysPasswordDigest(device.password()),
|
|
|
|
|
),
|
|
|
|
|
password: device.password(),
|
|
|
|
|
);
|
|
|
|
|
await remote.setKeysRecovery(
|
|
|
|
|
session,
|
|
|
|
|
person: carla,
|
|
|
|
|
proof: await device.ownerProof(
|
|
|
|
|
await signedInAgain(carla, 'sl-carla'),
|
|
|
|
|
challenge: await remote.beginPasswordKeyProof(session, carla),
|
|
|
|
|
purpose: KeyProofPurpose.newPhrase,
|
|
|
|
|
digest: newPhraseDigest(
|
|
|
|
|
next.recoveryKeys,
|
|
|
|
|
ByteData.sublistView(next.recoveryProofKey),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
recoveryKeys: next.recoveryKeys,
|
|
|
|
|
recoveryProofKey: ByteData.sublistView(next.recoveryProofKey),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
(await remote.beginPhraseKeyProof(
|
|
|
|
|
session,
|
|
|
|
|
carla,
|
|
|
|
|
)).recoveryKeys.toJson(),
|
|
|
|
|
next.recoveryKeys.toJson(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final deletion = await signedInAgain(carla, 'sl-carla');
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.deleteAccount(session, carla),
|
|
|
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
|
|
|
);
|
|
|
|
|
await remote.deleteAccount(
|
|
|
|
|
session,
|
|
|
|
|
carla,
|
|
|
|
|
keysProof: await device.ownerProof(
|
|
|
|
|
deletion,
|
|
|
|
|
challenge: await remote.beginPasswordKeyProof(session, carla),
|
|
|
|
|
purpose: KeyProofPurpose.deleteAccount,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(await AuthUser.db.findById(session, carla), isNull);
|
|
|
|
|
});
|
|
|
|
|
|
feat(base): mending a broken SimpleLogin link with the handle and the recovery phrase (ADR 0020, D7)
Someone whose SimpleLogin identity is new to the register but who has an
account ("I already have one") names their handle and proves the
account's recovery phrase; the new identity takes the old one's place.
- `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the
phrase's key, the public keys and a nonce, for the account with that
handle (whatever its case). Only an account with keys and a SimpleLogin
identity is mended, a link it had, as nobody adds SimpleLogin signed
out (D10); a handle nobody has, or any other account's, gets a decoy
from `signInDecoyKey`, the same each time. At most 10 an hour per
handle and 30 per network.
- `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`,
binding the attempt (`mendLinkDigest`, on both sides), so a proof
relayed from another sign-in links nobody's identity but the device's
own; checked for the waiting identity first, which leaves the proof
unspent. Every session of the account ends, in every service, and the
sign-in stands ten minutes for a change, such as a lost password.
- SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys
(`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a
SimpleLogin app and without it (`checkSimpleLoginMending`).
- Members ask the register for both; `RemoteRegister` and the instance
API carry them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:26:58 +02:00
|
|
|
test(
|
|
|
|
|
"mends a member's account's broken link with its handle and "
|
|
|
|
|
'recovery phrase, a decoy and its refusal crossing whole',
|
|
|
|
|
() async {
|
|
|
|
|
usePasswordKeys(pod, testPasswordKeys);
|
|
|
|
|
final device = await KeysDevice.make();
|
|
|
|
|
final first = await remote.beginSimpleLogin(
|
|
|
|
|
session,
|
2026-10-23 23:35:46 +02:00
|
|
|
marker: 'todo-loopback',
|
feat(base): mending a broken SimpleLogin link with the handle and the recovery phrase (ADR 0020, D7)
Someone whose SimpleLogin identity is new to the register but who has an
account ("I already have one") names their handle and proves the
account's recovery phrase; the new identity takes the old one's place.
- `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the
phrase's key, the public keys and a nonce, for the account with that
handle (whatever its case). Only an account with keys and a SimpleLogin
identity is mended, a link it had, as nobody adds SimpleLogin signed
out (D10); a handle nobody has, or any other account's, gets a decoy
from `signInDecoyKey`, the same each time. At most 10 an hour per
handle and 30 per network.
- `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`,
binding the attempt (`mendLinkDigest`, on both sides), so a proof
relayed from another sign-in links nobody's identity but the device's
own; checked for the waiting identity first, which leaves the proof
unspent. Every session of the account ends, in every service, and the
sign-in stands ten minutes for a change, such as a lost password.
- SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys
(`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a
SimpleLogin app and without it (`checkSimpleLoginMending`).
- Members ask the register for both; `RemoteRegister` and the instance
API carry them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:26:58 +02:00
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
);
|
|
|
|
|
await remote.completeSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: first.attempt,
|
|
|
|
|
code: simpleLogin.authorize(
|
|
|
|
|
first.authorizationUrl,
|
|
|
|
|
subject: 'sl-dora-old',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
final dora = await remote.signUpWithSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: first.attempt,
|
|
|
|
|
handle: 'Dora',
|
|
|
|
|
setup: device.setup(),
|
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.
So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.
Halkyon Learn's handle dialog words the new refusal (English, German).
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:39:14 +02:00
|
|
|
network: '203.0.113.0/24',
|
feat(base): mending a broken SimpleLogin link with the handle and the recovery phrase (ADR 0020, D7)
Someone whose SimpleLogin identity is new to the register but who has an
account ("I already have one") names their handle and proves the
account's recovery phrase; the new identity takes the old one's place.
- `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the
phrase's key, the public keys and a nonce, for the account with that
handle (whatever its case). Only an account with keys and a SimpleLogin
identity is mended, a link it had, as nobody adds SimpleLogin signed
out (D10); a handle nobody has, or any other account's, gets a decoy
from `signInDecoyKey`, the same each time. At most 10 an hour per
handle and 30 per network.
- `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`,
binding the attempt (`mendLinkDigest`, on both sides), so a proof
relayed from another sign-in links nobody's identity but the device's
own; checked for the waiting identity first, which leaves the proof
unspent. Every session of the account ends, in every service, and the
sign-in stands ten minutes for a change, such as a lost password.
- SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys
(`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a
SimpleLogin app and without it (`checkSimpleLoginMending`).
- Members ask the register for both; `RemoteRegister` and the instance
API carry them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:26:58 +02:00
|
|
|
);
|
|
|
|
|
final start = await remote.beginSimpleLogin(
|
|
|
|
|
session,
|
2026-10-23 23:35:46 +02:00
|
|
|
marker: 'todo-loopback',
|
feat(base): mending a broken SimpleLogin link with the handle and the recovery phrase (ADR 0020, D7)
Someone whose SimpleLogin identity is new to the register but who has an
account ("I already have one") names their handle and proves the
account's recovery phrase; the new identity takes the old one's place.
- `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the
phrase's key, the public keys and a nonce, for the account with that
handle (whatever its case). Only an account with keys and a SimpleLogin
identity is mended, a link it had, as nobody adds SimpleLogin signed
out (D10); a handle nobody has, or any other account's, gets a decoy
from `signInDecoyKey`, the same each time. At most 10 an hour per
handle and 30 per network.
- `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`,
binding the attempt (`mendLinkDigest`, on both sides), so a proof
relayed from another sign-in links nobody's identity but the device's
own; checked for the waiting identity first, which leaves the proof
unspent. Every session of the account ends, in every service, and the
sign-in stands ten minutes for a change, such as a lost password.
- SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys
(`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a
SimpleLogin app and without it (`checkSimpleLoginMending`).
- Members ask the register for both; `RemoteRegister` and the instance
API carry them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:26:58 +02:00
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
);
|
|
|
|
|
await remote.completeSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
code: simpleLogin.authorize(
|
|
|
|
|
start.authorizationUrl,
|
|
|
|
|
subject: 'sl-dora-new',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final decoy = await remote.beginSimpleLoginMend(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
handle: 'nobody',
|
|
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.mendSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
proof: await device.provePhrase(
|
|
|
|
|
decoy,
|
|
|
|
|
digest: mendLinkDigest(start.attempt),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
|
|
|
);
|
|
|
|
|
final mended = await remote.mendSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
proof: await device.provePhrase(
|
|
|
|
|
await remote.beginSimpleLoginMend(
|
|
|
|
|
session,
|
|
|
|
|
attempt: start.attempt,
|
|
|
|
|
handle: 'dora',
|
|
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
),
|
|
|
|
|
digest: mendLinkDigest(start.attempt),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(mended.outcome, SimpleLoginOutcome.signedIn);
|
|
|
|
|
expect(mended.account, dora);
|
|
|
|
|
expect(mended.endedBefore, isNotNull);
|
|
|
|
|
expect(
|
|
|
|
|
(await OidcIdentity.db.findFirstRow(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.authUserId.equals(dora),
|
|
|
|
|
))!.subject,
|
|
|
|
|
'sl-dora-new',
|
|
|
|
|
);
|
|
|
|
|
// Every member is told to end the account's sessions.
|
|
|
|
|
await sessionNoticesDelivered();
|
|
|
|
|
expect(members.sessionsTold.single.$2, dora);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)
The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.
The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:03:15 +02:00
|
|
|
test('refuses where the register has no SimpleLogin app', () async {
|
|
|
|
|
useSimpleLogin(sessionBuilder, null);
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.beginSimpleLogin(
|
|
|
|
|
session,
|
2026-10-23 23:35:46 +02:00
|
|
|
marker: 'todo-loopback',
|
feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)
The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.
The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:03:15 +02:00
|
|
|
network: '203.0.113.0/24',
|
|
|
|
|
),
|
|
|
|
|
refused(OidcRefusal.unknownProvider),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
group('passwords (ADR 0017)', () {
|
|
|
|
|
late DateTime now;
|
|
|
|
|
Future<T> atNow<T>(Future<T> Function() body) =>
|
|
|
|
|
withClock(Clock(() => now), body);
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
usePasswordKeys(pod, testPasswordKeys);
|
|
|
|
|
now = DateTime.utc(2026, 10, 20, 12);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('are offered as the register offers them', () async {
|
|
|
|
|
expect(await remote.passwordSignInOffered(session), isTrue);
|
|
|
|
|
usePasswordKeys(pod, null);
|
|
|
|
|
expect(await remote.passwordSignInOffered(session), isFalse);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.beginPasswordSignIn(
|
|
|
|
|
session,
|
|
|
|
|
address: 'ben@example.org',
|
|
|
|
|
network: '203.0.113.7',
|
|
|
|
|
),
|
|
|
|
|
refused(PasswordRefusal.unavailable),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("cross for a member's device, every operation, with what the "
|
|
|
|
|
'register refuses', () async {
|
|
|
|
|
final device = await PasswordDevice.make();
|
|
|
|
|
await atNow(() async {
|
|
|
|
|
final annaOnTodo = const Uuid().v7obj();
|
|
|
|
|
final request = await remote.sendSignInCode(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
network: '203.0.113.7',
|
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,
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
);
|
|
|
|
|
await remote.setPassword(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
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
|
|
|
anonymousHere: true,
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
setup: device.setup(),
|
|
|
|
|
request: request,
|
|
|
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
|
|
|
|
|
contains(identified.name),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.setPassword(
|
|
|
|
|
session,
|
|
|
|
|
person: ben,
|
|
|
|
|
setup: device.setup().copyWith(salt: ByteData(3)),
|
|
|
|
|
),
|
|
|
|
|
refused(PasswordRefusal.invalidSetup),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final enrolment = await remote.enrolAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
name: 'Phone',
|
|
|
|
|
label: 'anna@example.org',
|
|
|
|
|
);
|
|
|
|
|
final codes = await remote.confirmAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
authenticator: enrolment.authenticator,
|
|
|
|
|
code: codeOf(enrolment),
|
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
|
|
|
proof: device.ownerProof(
|
|
|
|
|
await remote.beginPasswordChange(session, person: annaOnTodo),
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
purpose: PasswordProofPurpose.firstAuthenticator,
|
|
|
|
|
digest: authenticatorDigest(enrolment.authenticator),
|
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
|
|
|
),
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
);
|
|
|
|
|
expect(codes, hasLength(recoveryCodeCount));
|
|
|
|
|
expect(
|
|
|
|
|
(await remote.authenticatorsOf(session, annaOnTodo)).single.id,
|
|
|
|
|
enrolment.authenticator,
|
|
|
|
|
);
|
|
|
|
|
final spare = await remote.enrolAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
name: 'Spare',
|
|
|
|
|
label: '',
|
|
|
|
|
);
|
2026-10-20 14:12:48 +02:00
|
|
|
await expectLater(
|
|
|
|
|
remote.confirmAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
authenticator: spare.authenticator,
|
|
|
|
|
code: codeOf(spare),
|
|
|
|
|
),
|
fix(auth)!: a session alone never gets a code checked
The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.
A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.
The app follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
|
|
|
refused(PasswordRefusal.wrongCredentials),
|
2026-10-20 14:12:48 +02:00
|
|
|
);
|
|
|
|
|
now = now.add(totpPeriod);
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
Future<OwnerProof> ownerProof(
|
|
|
|
|
PasswordProofPurpose purpose, {
|
|
|
|
|
List<int> digest = const [],
|
|
|
|
|
}) async => device.ownerProof(
|
fix(auth)!: a session alone never gets a code checked
The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.
A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.
The app follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
|
|
|
await remote.beginPasswordChange(session, person: annaOnTodo),
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
purpose: purpose,
|
|
|
|
|
digest: digest,
|
fix(auth)!: a session alone never gets a code checked
The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.
A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.
The app follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
|
|
|
totpCode: codeOf(enrolment),
|
|
|
|
|
);
|
2026-10-20 14:12:48 +02:00
|
|
|
expect(
|
|
|
|
|
await remote.confirmAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
authenticator: spare.authenticator,
|
|
|
|
|
code: codeOf(spare),
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
proof: await ownerProof(
|
|
|
|
|
PasswordProofPurpose.addAuthenticator,
|
|
|
|
|
digest: authenticatorDigest(spare.authenticator),
|
|
|
|
|
),
|
2026-10-20 14:12:48 +02:00
|
|
|
),
|
|
|
|
|
isEmpty,
|
|
|
|
|
);
|
|
|
|
|
now = now.add(totpPeriod);
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
await remote.removeAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
authenticator: spare.authenticator,
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
proof: await ownerProof(
|
|
|
|
|
PasswordProofPurpose.removeAuthenticator,
|
|
|
|
|
digest: authenticatorDigest(spare.authenticator),
|
|
|
|
|
),
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
);
|
|
|
|
|
final ways = await remote.signInWaysOf(session, annaOnTodo);
|
|
|
|
|
expect(ways.password && ways.authenticator, isTrue);
|
2026-10-21 12:05:18 +02:00
|
|
|
expect(ways.sessionEndedForProofsAt, isNull);
|
|
|
|
|
// A member's session ended for its wrong proofs: the owner's
|
|
|
|
|
// warning, kept here, read and dismissed through the member.
|
|
|
|
|
await remote.noteSessionEndedForProofs(session, annaOnTodo);
|
|
|
|
|
final warned = now;
|
|
|
|
|
expect(
|
|
|
|
|
(await remote.signInWaysOf(
|
|
|
|
|
session,
|
|
|
|
|
annaOnTodo,
|
|
|
|
|
)).sessionEndedForProofsAt,
|
|
|
|
|
warned,
|
|
|
|
|
);
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
|
|
|
|
|
now = now.add(totpPeriod);
|
|
|
|
|
final challenge = await remote.beginPasswordSignIn(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
network: '203.0.113.7',
|
|
|
|
|
);
|
|
|
|
|
final answer = device.answer(challenge);
|
|
|
|
|
final proof = await remote.completePasswordSignIn(
|
|
|
|
|
session,
|
|
|
|
|
challenge: challenge.challenge,
|
|
|
|
|
clientValue: answer.clientValue,
|
|
|
|
|
clientProof: answer.clientProof,
|
|
|
|
|
totpCode: codeOf(enrolment, at: now),
|
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(proof.account, annaOnTodo);
|
|
|
|
|
expect(answer.verifiesServer(proof.serverProof), isTrue);
|
|
|
|
|
expect(proof.passwordKeys.toJson(), device.passwordKeys.toJson());
|
2026-10-21 12:05:18 +02:00
|
|
|
expect(proof.sessionEndedForProofsAt, warned);
|
2026-10-21 13:32:33 +02:00
|
|
|
// An older session of the member's dismisses nothing (the full
|
|
|
|
|
// reading's L1); one made since does.
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.dismissSessionEndedWarning(
|
|
|
|
|
session,
|
|
|
|
|
annaOnTodo,
|
|
|
|
|
sessionMadeAt: warned,
|
|
|
|
|
),
|
|
|
|
|
refused(PasswordRefusal.signedInBefore),
|
|
|
|
|
);
|
|
|
|
|
await remote.dismissSessionEndedWarning(
|
|
|
|
|
session,
|
|
|
|
|
annaOnTodo,
|
|
|
|
|
sessionMadeAt: warned.add(const Duration(seconds: 1)),
|
|
|
|
|
);
|
2026-10-21 12:05:18 +02:00
|
|
|
expect(
|
|
|
|
|
(await remote.signInWaysOf(
|
|
|
|
|
session,
|
|
|
|
|
annaOnTodo,
|
|
|
|
|
)).sessionEndedForProofsAt,
|
|
|
|
|
isNull,
|
|
|
|
|
);
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
final again = await remote.beginPasswordSignIn(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
network: '203.0.113.7',
|
|
|
|
|
);
|
|
|
|
|
final againAnswer = device.answer(again);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.completePasswordSignIn(
|
|
|
|
|
session,
|
|
|
|
|
challenge: again.challenge,
|
|
|
|
|
clientValue: againAnswer.clientValue,
|
|
|
|
|
clientProof: againAnswer.clientProof,
|
|
|
|
|
totpCode: codeOf(enrolment, at: now),
|
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
|
|
|
),
|
|
|
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
now = now.add(totpPeriod);
|
|
|
|
|
expect(
|
|
|
|
|
await remote.newRecoveryCodes(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
proof: await ownerProof(PasswordProofPurpose.newRecoveryCodes),
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
),
|
|
|
|
|
hasLength(recoveryCodeCount),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final keyChallenge = await remote.beginKeyProof(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
network: '203.0.113.7',
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
keyChallenge.recoveryKeys.toJson(),
|
|
|
|
|
device.recoveryKeys.toJson(),
|
|
|
|
|
);
|
|
|
|
|
now = now.add(totpPeriod);
|
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
|
|
|
final newPassword = device.newPassword();
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
expect(
|
|
|
|
|
await remote.resetPasswordWithPhrase(
|
|
|
|
|
session,
|
|
|
|
|
challenge: keyChallenge.challenge,
|
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
|
|
|
signature: await device.sign(
|
|
|
|
|
keyChallenge,
|
|
|
|
|
password: newPassword,
|
|
|
|
|
),
|
|
|
|
|
password: newPassword,
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
totpCode: codeOf(enrolment, at: now),
|
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
|
|
|
),
|
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
|
|
|
(account: annaOnTodo, endedBefore: now),
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
);
|
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
|
|
|
// Every member is told to end the account's sessions too.
|
2026-10-21 13:28:03 +02:00
|
|
|
await sessionNoticesDelivered();
|
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(members.sessionsTold.single, (_todo, annaOnTodo, now, null));
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
|
|
|
|
|
final forReset = await remote.beginPasswordSignIn(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
network: '203.0.113.7',
|
|
|
|
|
);
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
final resetAnswer = device.answer(
|
|
|
|
|
forReset,
|
|
|
|
|
purpose: PasswordProofPurpose.authenticatorReset,
|
|
|
|
|
);
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
final resetRequest = await remote.sendSignInCode(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
network: '203.0.113.7',
|
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.authenticatorReset,
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
);
|
|
|
|
|
final pending = await remote.resetAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
challenge: forReset.challenge,
|
|
|
|
|
clientValue: resetAnswer.clientValue,
|
|
|
|
|
clientProof: resetAnswer.clientProof,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
request: resetRequest,
|
|
|
|
|
code: mailbox.codeFor('anna@example.org'),
|
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,
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
);
|
|
|
|
|
expect(pending.takesEffectAt, now.add(resetDelay));
|
|
|
|
|
|
|
|
|
|
final startRequest = await remote.sendSignInCode(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
network: '203.0.113.7',
|
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.accountReset,
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
);
|
|
|
|
|
await remote.startAccountReset(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
request: startRequest,
|
|
|
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
|
|
|
);
|
|
|
|
|
now = now.add(resetDelay);
|
|
|
|
|
final fresh = await PasswordDevice.make(seed: 99);
|
|
|
|
|
final completeRequest = await remote.sendSignInCode(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
network: '203.0.113.7',
|
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.accountReset,
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
await remote.completeAccountReset(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
request: completeRequest,
|
|
|
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
|
|
|
setup: fresh.setup(),
|
|
|
|
|
),
|
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
|
|
|
(account: annaOnTodo, endedBefore: now),
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
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('changes while signed in (ADR 0017)', () {
|
|
|
|
|
late DateTime now;
|
|
|
|
|
Future<T> atNow<T>(Future<T> Function() body) =>
|
|
|
|
|
withClock(Clock(() => now), body);
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
usePasswordKeys(pod, testPasswordKeys);
|
|
|
|
|
now = DateTime.utc(2026, 10, 20, 12);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
"cross for a member's device: the password, keeping the "
|
|
|
|
|
"member's session, the phrase's wrapping, and a reset's date",
|
|
|
|
|
() async {
|
|
|
|
|
final device = await PasswordDevice.make();
|
|
|
|
|
await atNow(() async {
|
|
|
|
|
final annaOnTodo = const Uuid().v7obj();
|
|
|
|
|
final annasPhone = const Uuid().v7obj();
|
|
|
|
|
final request = await remote.sendSignInCode(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
network: '203.0.113.7',
|
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,
|
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
|
|
|
);
|
|
|
|
|
await remote.setPassword(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
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
|
|
|
anonymousHere: true,
|
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
|
|
|
setup: device.setup(),
|
|
|
|
|
request: request,
|
|
|
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
|
|
|
);
|
|
|
|
|
final enrolment = await remote.enrolAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
name: 'Phone',
|
|
|
|
|
label: 'anna@example.org',
|
|
|
|
|
);
|
|
|
|
|
await remote.confirmAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
authenticator: enrolment.authenticator,
|
|
|
|
|
code: codeOf(enrolment),
|
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
|
|
|
proof: device.ownerProof(
|
|
|
|
|
await remote.beginPasswordChange(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
),
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
purpose: PasswordProofPurpose.firstAuthenticator,
|
|
|
|
|
digest: authenticatorDigest(enrolment.authenticator),
|
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
|
|
|
),
|
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
|
|
|
);
|
|
|
|
|
now = now.add(totpPeriod);
|
|
|
|
|
|
|
|
|
|
final challenge = await remote.beginPasswordChange(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
);
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
// The old password proves it: the device takes the new one's
|
|
|
|
|
// x as it makes it.
|
|
|
|
|
final old = device.privateValue;
|
|
|
|
|
final newPassword = device.newPassword();
|
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
|
|
|
await remote.changePassword(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
fix(auth)!: a session alone never gets a code checked
The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.
A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.
The app follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
|
|
|
proof: device.ownerProof(
|
|
|
|
|
challenge,
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
purpose: PasswordProofPurpose.newPassword,
|
|
|
|
|
digest: newPasswordDigest(newPassword),
|
|
|
|
|
privateValue: old,
|
fix(auth)!: a session alone never gets a code checked
The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.
A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.
The app follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
|
|
|
totpCode: codeOf(enrolment),
|
|
|
|
|
),
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
password: newPassword,
|
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
|
|
|
keepSession: annasPhone,
|
|
|
|
|
);
|
2026-10-21 13:28:03 +02:00
|
|
|
await sessionNoticesDelivered();
|
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(members.sessionsTold.single, (
|
|
|
|
|
_todo,
|
|
|
|
|
annaOnTodo,
|
|
|
|
|
now,
|
|
|
|
|
annasPhone,
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
now = now.add(totpPeriod);
|
|
|
|
|
final fresh = WrappedKeys(
|
|
|
|
|
nonce: ByteData(24),
|
|
|
|
|
cipherText: ByteData(wrappedKeysLength),
|
|
|
|
|
);
|
2026-10-20 20:01:07 +02:00
|
|
|
Future<void> newPhrase() async {
|
|
|
|
|
final challenge = await remote.beginPasswordChange(
|
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
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
2026-10-20 20:01:07 +02:00
|
|
|
);
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
final proofKey = ByteData.sublistView(device.recoveryProofKey);
|
2026-10-20 20:01:07 +02:00
|
|
|
await remote.setRecoveryKeys(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
fix(auth)!: a session alone never gets a code checked
The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.
A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.
The app follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
|
|
|
proof: device.ownerProof(
|
|
|
|
|
challenge,
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
purpose: PasswordProofPurpose.newPhrase,
|
|
|
|
|
digest: newPhraseDigest(fresh, proofKey),
|
fix(auth)!: a session alone never gets a code checked
The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.
A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.
The app follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
|
|
|
totpCode: codeOf(enrolment),
|
|
|
|
|
),
|
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
|
|
|
recoveryKeys: fresh,
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
recoveryProofKey: proofKey,
|
2026-10-20 20:01:07 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await newPhrase();
|
|
|
|
|
// The same step's code again.
|
|
|
|
|
await expectLater(
|
|
|
|
|
newPhrase(),
|
|
|
|
|
refused(PasswordRefusal.wrongCredentials),
|
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
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final start = await remote.sendSignInCode(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
network: '203.0.113.7',
|
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.accountReset,
|
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
|
|
|
);
|
|
|
|
|
final pending = await remote.startAccountReset(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
request: start,
|
|
|
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
|
|
|
);
|
|
|
|
|
final early = await remote.sendSignInCode(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
network: '203.0.113.7',
|
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.accountReset,
|
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
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.completeAccountReset(
|
|
|
|
|
session,
|
|
|
|
|
address: 'anna@example.org',
|
|
|
|
|
request: early,
|
|
|
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
|
|
|
setup: device.setup(),
|
|
|
|
|
),
|
|
|
|
|
throwsA(
|
|
|
|
|
isA<PasswordRefusedException>().having(
|
|
|
|
|
(error) => error.takesEffectAt,
|
|
|
|
|
'takesEffectAt',
|
|
|
|
|
pending.takesEffectAt,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
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
|
|
|
|
|
|
|
|
// The member's device is shown the reset, and its owner calls
|
|
|
|
|
// it off with their proof (the full reading's H1).
|
|
|
|
|
expect(
|
|
|
|
|
(await remote.signInWaysOf(session, annaOnTodo)).accountResetAt,
|
|
|
|
|
pending.takesEffectAt,
|
|
|
|
|
);
|
|
|
|
|
now = now.add(totpPeriod);
|
|
|
|
|
await remote.callOffResets(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
proof: device.ownerProof(
|
|
|
|
|
await remote.beginPasswordChange(
|
|
|
|
|
session,
|
|
|
|
|
person: annaOnTodo,
|
|
|
|
|
),
|
fix(auth)!: the owner's proof is bound to what it approves
A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).
SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:12:11 +02:00
|
|
|
purpose: PasswordProofPurpose.callOffResets,
|
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
|
|
|
totpCode: codeOf(enrolment),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
(await remote.signInWaysOf(session, annaOnTodo)).accountResetAt,
|
|
|
|
|
isNull,
|
|
|
|
|
);
|
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-20 14:41:16 +02:00
|
|
|
group('ways in without a working password (ADR 0017)', () {
|
|
|
|
|
late DateTime now;
|
|
|
|
|
Future<T> atNow<T>(Future<T> Function() body) =>
|
|
|
|
|
withClock(Clock(() => now), body);
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
usePasswordKeys(pod, testPasswordKeys);
|
|
|
|
|
now = DateTime.utc(2026, 10, 20, 12);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("cross for a member's device: signing up with a code, and a "
|
|
|
|
|
'setup never finished signing in with the password and a code, '
|
|
|
|
|
'with what the register refuses', () async {
|
|
|
|
|
final device = await PasswordDevice.make();
|
|
|
|
|
await atNow(() async {
|
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
|
|
|
Future<UuidValue> code([
|
|
|
|
|
EmailCodePurpose purpose = EmailCodePurpose.signUp,
|
|
|
|
|
]) => remote.sendSignInCode(
|
2026-10-20 14:41:16 +02:00
|
|
|
session,
|
|
|
|
|
address: 'carla@example.org',
|
|
|
|
|
language: 'en',
|
|
|
|
|
network: '203.0.113.7',
|
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: purpose,
|
2026-10-20 14:41:16 +02:00
|
|
|
);
|
|
|
|
|
final carla = await remote.signUpWithCode(
|
|
|
|
|
session,
|
|
|
|
|
request: await code(),
|
|
|
|
|
code: mailbox.codeFor('carla@example.org'),
|
|
|
|
|
setup: device.setup(),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
(await AuthUser.db.findById(session, carla))!.scopeNames,
|
|
|
|
|
contains(identified.name),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
remote.signUpWithCode(
|
|
|
|
|
session,
|
|
|
|
|
request: await code(),
|
|
|
|
|
code: mailbox.codeFor('carla@example.org'),
|
|
|
|
|
setup: device.setup(),
|
|
|
|
|
),
|
|
|
|
|
refused(PasswordRefusal.passwordExists),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final challenge = await remote.beginPasswordSignIn(
|
|
|
|
|
session,
|
|
|
|
|
address: 'carla@example.org',
|
|
|
|
|
network: '203.0.113.7',
|
|
|
|
|
);
|
|
|
|
|
final answer = device.answer(challenge);
|
|
|
|
|
final proof = await remote.completePasswordSignIn(
|
|
|
|
|
session,
|
|
|
|
|
challenge: challenge.challenge,
|
|
|
|
|
clientValue: answer.clientValue,
|
|
|
|
|
clientProof: answer.clientProof,
|
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
|
|
|
request: await code(EmailCodePurpose.finishSetup),
|
2026-10-20 14:41:16 +02:00
|
|
|
code: mailbox.codeFor('carla@example.org'),
|
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: 'carla@example.org',
|
|
|
|
|
language: 'en',
|
2026-10-20 14:41:16 +02:00
|
|
|
);
|
|
|
|
|
expect(proof.account, carla);
|
|
|
|
|
expect(proof.needsAuthenticator, isTrue);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
group('deleting an account', () {
|
|
|
|
|
test('deletes it here and tells every member, keeping no notice '
|
|
|
|
|
'once they confirmed', () async {
|
|
|
|
|
await remote.deleteAccount(session, ben);
|
|
|
|
|
|
|
|
|
|
expect(await AuthUser.db.findById(session, ben), isNull);
|
|
|
|
|
expect(await EmailIdentity.db.count(session), 0);
|
|
|
|
|
expect(await GitHubAccount.db.count(session), 0);
|
|
|
|
|
expect(members.told, [(_todo, ben)]);
|
|
|
|
|
expect(await AccountDeletionNotice.db.count(session), 0);
|
|
|
|
|
expect(await remote.knownAccounts(session, {ben}), isEmpty);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'tells a member that does not answer again until it does',
|
|
|
|
|
() async {
|
|
|
|
|
members.answers = (member) => member != _todo;
|
|
|
|
|
|
|
|
|
|
await remote.deleteAccount(session, ben);
|
|
|
|
|
|
|
|
|
|
expect(await AuthUser.db.findById(session, ben), isNull);
|
|
|
|
|
final kept = await AccountDeletionNotice.db.find(session);
|
|
|
|
|
expect(kept.single.account, ben);
|
|
|
|
|
expect(kept.single.member, '$_todo');
|
|
|
|
|
expect(kept.single.attempts, 1);
|
|
|
|
|
expect(kept.single.lastAttemptAt, isNotNull);
|
|
|
|
|
|
|
|
|
|
expect(await deliverDeletionNotices(session), 0);
|
|
|
|
|
expect(
|
|
|
|
|
(await AccountDeletionNotice.db.find(session)).single.attempts,
|
|
|
|
|
2,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
members.answers = (_) => true;
|
|
|
|
|
expect(await deliverDeletionNotices(session), 1);
|
|
|
|
|
expect(await AccountDeletionNotice.db.count(session), 0);
|
|
|
|
|
expect(members.told, [(_todo, ben), (_todo, ben), (_todo, ben)]);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('tells the members of an account this server no longer has, '
|
|
|
|
|
'as one may still hold its data', () async {
|
|
|
|
|
final gone = const Uuid().v7obj();
|
|
|
|
|
|
|
|
|
|
await remote.deleteAccount(session, gone);
|
|
|
|
|
|
|
|
|
|
expect(members.told, [(_todo, gone)]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('tells nobody of an anonymous person, a member never having '
|
|
|
|
|
'had them', () async {
|
|
|
|
|
final anna = await newAnonymousPerson(session);
|
|
|
|
|
|
|
|
|
|
await remote.deleteAccount(session, anna);
|
|
|
|
|
|
|
|
|
|
expect(await AuthUser.db.findById(session, anna), isNull);
|
|
|
|
|
expect(members.told, isEmpty);
|
|
|
|
|
expect(await AccountDeletionNotice.db.count(session), 0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'drops the notices of a member the operator no longer names',
|
|
|
|
|
() async {
|
|
|
|
|
members.answers = (_) => false;
|
|
|
|
|
useBase(
|
|
|
|
|
sessionBuilder,
|
|
|
|
|
accounts: HoldsRegister(members: [_todo, _train]),
|
|
|
|
|
);
|
|
|
|
|
await remote.deleteAccount(session, ben);
|
|
|
|
|
expect(await AccountDeletionNotice.db.count(session), 2);
|
|
|
|
|
|
|
|
|
|
useBase(sessionBuilder, accounts: HoldsRegister(members: [_train]));
|
|
|
|
|
await deliverDeletionNotices(session);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
(await AccountDeletionNotice.db.find(session)).single.member,
|
|
|
|
|
'$_train',
|
|
|
|
|
);
|
|
|
|
|
useBase(sessionBuilder);
|
|
|
|
|
await deliverDeletionNotices(session);
|
|
|
|
|
expect(await AccountDeletionNotice.db.count(session), 0);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
group('A member asking the register', () {
|
|
|
|
|
late List<http.Request> requests;
|
|
|
|
|
|
|
|
|
|
/// A register answering every request with [status] and [body].
|
|
|
|
|
RemoteRegister answering(int status, String body) {
|
|
|
|
|
requests = [];
|
|
|
|
|
return RemoteRegister(
|
|
|
|
|
Uri.parse('http://learn.test:8082'),
|
|
|
|
|
secret: _secret,
|
|
|
|
|
client: () => MockClient((request) async {
|
|
|
|
|
requests.add(request);
|
|
|
|
|
return http.Response(body, status);
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'posts JSON to the operation with the secret as its bearer token',
|
|
|
|
|
() async {
|
|
|
|
|
final account = const Uuid().v7obj();
|
|
|
|
|
final register = answering(200, '{"account": "${account.uuid}"}');
|
|
|
|
|
|
|
|
|
|
expect(await register.accountWithGitHub(_noSession, '1001'), account);
|
|
|
|
|
final request = requests.single;
|
|
|
|
|
expect(request.method, 'POST');
|
|
|
|
|
expect(
|
|
|
|
|
'${request.url}',
|
|
|
|
|
'http://learn.test:8082/halkyon-base/register/account-with-github',
|
|
|
|
|
);
|
|
|
|
|
expect(request.headers['authorization'], 'Bearer $_secret');
|
|
|
|
|
expect(jsonDecode(request.body), {'gitHubUserId': '1001'});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'fails as unavailable for an answer it cannot take, never guessing',
|
|
|
|
|
() async {
|
|
|
|
|
for (final (status, body) in [
|
|
|
|
|
(500, '{}'),
|
|
|
|
|
(401, '{"error": "secret"}'),
|
|
|
|
|
(200, 'not JSON'),
|
|
|
|
|
(200, '[]'),
|
|
|
|
|
(409, '{"refusal": {"kind": "email", "reason": "unheardOf"}}'),
|
|
|
|
|
(409, '{"refusal": "no"}'),
|
|
|
|
|
(404, '{"error": "not here"}'),
|
|
|
|
|
]) {
|
|
|
|
|
await expectLater(
|
|
|
|
|
answering(status, body).accountWithGitHub(_noSession, '1001'),
|
|
|
|
|
throwsA(isA<RegisterUnavailableException>()),
|
|
|
|
|
reason: '$status $body',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
await expectLater(
|
|
|
|
|
RemoteRegister(
|
|
|
|
|
Uri.parse('http://learn.test:8082'),
|
|
|
|
|
secret: _secret,
|
|
|
|
|
client: () => MockClient(
|
|
|
|
|
(_) async => throw http.ClientException('Connection refused'),
|
|
|
|
|
),
|
|
|
|
|
).accountWithGitHub(_noSession, '1001'),
|
|
|
|
|
throwsA(isA<RegisterUnavailableException>()),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('The register telling a member', () {
|
|
|
|
|
test("posts the account to the member's path with the secret, and "
|
|
|
|
|
'counts only a 200 as confirmed', () async {
|
|
|
|
|
final account = const Uuid().v7obj();
|
|
|
|
|
final requests = <http.Request>[];
|
|
|
|
|
var status = 200;
|
|
|
|
|
final notifier = HttpMemberNotifier(
|
|
|
|
|
secret: _secret,
|
|
|
|
|
client: MockClient((request) async {
|
|
|
|
|
requests.add(request);
|
|
|
|
|
return http.Response('{}', status);
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(await notifier.accountGone(_todo, account), isTrue);
|
|
|
|
|
final request = requests.single;
|
|
|
|
|
expect(request.method, 'POST');
|
|
|
|
|
expect('${request.url}', 'http://todo.test:8082$accountGonePath');
|
|
|
|
|
expect(request.headers['authorization'], 'Bearer $_secret');
|
|
|
|
|
expect(jsonDecode(request.body), {'account': account.uuid});
|
|
|
|
|
|
|
|
|
|
status = 401;
|
|
|
|
|
expect(await notifier.accountGone(_todo, account), isFalse);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('counts a failed request as not confirmed, and sends nothing '
|
|
|
|
|
'without a secret', () async {
|
|
|
|
|
final account = const Uuid().v7obj();
|
|
|
|
|
var sent = 0;
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
await HttpMemberNotifier(
|
|
|
|
|
secret: _secret,
|
|
|
|
|
client: MockClient((_) {
|
|
|
|
|
sent++;
|
|
|
|
|
return Future.error(http.ClientException('Connection refused'));
|
|
|
|
|
}),
|
|
|
|
|
).accountGone(_todo, account),
|
|
|
|
|
isFalse,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
await HttpMemberNotifier(
|
|
|
|
|
secret: null,
|
|
|
|
|
client: MockClient((_) async {
|
|
|
|
|
sent++;
|
|
|
|
|
return http.Response('{}', 200);
|
|
|
|
|
}),
|
|
|
|
|
).accountGone(_todo, account),
|
|
|
|
|
isFalse,
|
|
|
|
|
);
|
|
|
|
|
expect(sent, 1);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// No session: [RemoteRegister] only passes it on.
|
|
|
|
|
final Session _noSession = _NoSession();
|
|
|
|
|
|
|
|
|
|
final class _NoSession implements Session {
|
|
|
|
|
@override
|
|
|
|
|
Object? noSuchMethod(Invocation invocation) =>
|
|
|
|
|
throw UnsupportedError('no session in this test');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _deleteAll(Session session) async {
|
feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)
The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.
The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:03:15 +02:00
|
|
|
await OidcAttempt.db.deleteWhere(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.id.notEquals(null),
|
|
|
|
|
);
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
await SignInChallenge.db.deleteWhere(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.id.notEquals(null),
|
|
|
|
|
);
|
|
|
|
|
await AccountReset.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
|
|
|
await RecoveryCode.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
|
|
|
await TotpAuthenticator.db.deleteWhere(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.id.notEquals(null),
|
|
|
|
|
);
|
|
|
|
|
await AccountPassword.db.deleteWhere(session, where: (t) => t.id > 0);
|
2026-10-19 23:10:08 +02:00
|
|
|
await AccountDeletionNotice.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
|
|
|
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
|
|
|
await RateLimitedRequestAttempt.db.deleteWhere(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.id.notEquals(null),
|
|
|
|
|
);
|
|
|
|
|
await EmailCodeRequest.db.deleteWhere(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.id.notEquals(null),
|
|
|
|
|
);
|
|
|
|
|
await SecretChallenge.db.deleteWhere(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.id.notEquals(null),
|
|
|
|
|
);
|
|
|
|
|
await EmailIdentity.db.deleteWhere(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.id.notEquals(null),
|
|
|
|
|
);
|
|
|
|
|
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
|
|
|
|
}
|