2026-10-19 23:10:08 +02:00
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
import 'package:clock/clock.dart';
|
2026-10-19 23:10:08 +02:00
|
|
|
import 'package:halkyon_base_server/src/auth/account.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/halkyon_base.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/register/accounts_mode.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/register/local_register.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/register/register_protocol.dart';
|
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
|
|
|
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
|
2026-10-19 23:10:08 +02:00
|
|
|
import 'package:serverpod/serverpod.dart';
|
|
|
|
|
import 'package:serverpod_auth_idp_server/core.dart';
|
|
|
|
|
|
|
|
|
|
// The calls between an instance's services (ADR 0016), read into plain values
|
|
|
|
|
// so tests drive them without a web server; `instance_routes.dart` puts them
|
|
|
|
|
// on the web server. Every call carries the instance's shared secret: without
|
|
|
|
|
// it, or on a server without one, nothing is answered.
|
|
|
|
|
|
|
|
|
|
/// What a call between the instance's services is answered: an HTTP status
|
|
|
|
|
/// and a JSON body.
|
|
|
|
|
typedef InstanceAnswer = ({int status, Map<String, Object?> body});
|
|
|
|
|
|
|
|
|
|
const _unauthorized = (status: 401, body: <String, Object?>{'error': 'secret'});
|
|
|
|
|
const _notHere = (status: 404, body: <String, Object?>{'error': 'not here'});
|
|
|
|
|
const _unreadable = (status: 400, body: <String, Object?>{'error': 'request'});
|
|
|
|
|
|
|
|
|
|
/// Whether [authorization] (a request's header) carries the instance's
|
|
|
|
|
/// secret, which [pod] must have.
|
|
|
|
|
bool _carriesSecret(Serverpod pod, String? authorization) {
|
|
|
|
|
final secret = instanceSecretOf(pod);
|
|
|
|
|
if (secret == null || authorization == null) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const scheme = 'Bearer ';
|
|
|
|
|
return authorization.startsWith(scheme) &&
|
|
|
|
|
isInstanceSecret(authorization.substring(scheme.length), secret);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// [body] as a JSON object; null for anything else.
|
|
|
|
|
Map<String, Object?>? _arguments(String body) {
|
|
|
|
|
try {
|
|
|
|
|
return switch (jsonDecode(body)) {
|
|
|
|
|
final Map<String, Object?> arguments => arguments,
|
|
|
|
|
_ => null,
|
|
|
|
|
};
|
|
|
|
|
} on FormatException {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The register answering a member's [operation] (`register_protocol.dart`),
|
|
|
|
|
/// asked with [body] (JSON) and [authorization] (the request's header). On
|
|
|
|
|
/// a server that is a member itself, nothing is answered.
|
|
|
|
|
Future<InstanceAnswer> answerMember(
|
|
|
|
|
Session session,
|
|
|
|
|
String operation,
|
|
|
|
|
String body, {
|
|
|
|
|
required String? authorization,
|
|
|
|
|
}) async {
|
|
|
|
|
final pod = session.serverpod;
|
|
|
|
|
if (!_carriesSecret(pod, authorization)) {
|
|
|
|
|
return _unauthorized;
|
|
|
|
|
}
|
|
|
|
|
if (HalkyonBase.maybeOf(pod)?.accounts is MemberOfRegister) {
|
|
|
|
|
return _notHere;
|
|
|
|
|
}
|
|
|
|
|
final arguments = _arguments(body);
|
|
|
|
|
if (arguments == null) {
|
|
|
|
|
return _unreadable;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
final answer = await _answer(session, operation, _Arguments(arguments));
|
|
|
|
|
return answer == null ? _notHere : (status: 200, body: answer);
|
|
|
|
|
} on FormatException {
|
|
|
|
|
return _unreadable;
|
|
|
|
|
} on ProfileNotFoundException {
|
|
|
|
|
return (status: 404, body: <String, Object?>{'notFound': 'profile'});
|
|
|
|
|
} on Exception catch (error) {
|
|
|
|
|
if (refusalJson(error) case final refusal?) {
|
|
|
|
|
return (status: 409, body: <String, Object?>{'refusal': refusal});
|
|
|
|
|
}
|
|
|
|
|
rethrow;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// [operation]'s answer from this server's own register; null for an
|
|
|
|
|
/// operation it doesn't know.
|
|
|
|
|
Future<Map<String, Object?>?> _answer(
|
|
|
|
|
Session session,
|
|
|
|
|
String operation,
|
|
|
|
|
_Arguments arguments,
|
|
|
|
|
) async {
|
|
|
|
|
const register = LocalRegister();
|
|
|
|
|
switch (operation) {
|
|
|
|
|
case RegisterOperation.profileOf:
|
|
|
|
|
final profile = await register.profileOf(session, arguments.id('person'));
|
|
|
|
|
return {'profile': profile?.toJson()};
|
|
|
|
|
case RegisterOperation.profilesOf:
|
|
|
|
|
final profiles = await register.profilesOf(
|
|
|
|
|
session,
|
|
|
|
|
arguments.ids('people'),
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
'profiles': {
|
|
|
|
|
for (final MapEntry(:key, :value) in profiles.entries)
|
|
|
|
|
key.uuid: value.toJson(),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
case RegisterOperation.profileByHandle:
|
|
|
|
|
final profile = await register.profileByHandle(
|
|
|
|
|
session,
|
|
|
|
|
arguments.string('handle'),
|
|
|
|
|
);
|
|
|
|
|
return {'profile': profile?.toJson()};
|
|
|
|
|
case RegisterOperation.personWithHandle:
|
|
|
|
|
final person = await register.personWithHandle(
|
|
|
|
|
session,
|
|
|
|
|
arguments.string('handle'),
|
|
|
|
|
);
|
|
|
|
|
return {'person': person?.uuid};
|
|
|
|
|
case RegisterOperation.saveProfile:
|
|
|
|
|
final profile = await register.saveProfile(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
handle: arguments.string('handle'),
|
|
|
|
|
displayName: arguments.string('displayName'),
|
|
|
|
|
bio: arguments.string('bio'),
|
|
|
|
|
links: arguments.strings('links'),
|
|
|
|
|
);
|
|
|
|
|
return {'profile': profile.toJson()};
|
|
|
|
|
case RegisterOperation.checkHandle:
|
|
|
|
|
final refusal = await register.checkHandle(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
arguments.string('handle'),
|
|
|
|
|
);
|
|
|
|
|
return {'refusal': refusal?.name};
|
|
|
|
|
case RegisterOperation.checkLinks:
|
|
|
|
|
final profile = await register.checkLinks(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
);
|
|
|
|
|
return {'profile': profile.toJson()};
|
|
|
|
|
case RegisterOperation.setAvatar:
|
|
|
|
|
final Uint8List png;
|
|
|
|
|
try {
|
|
|
|
|
png = base64Decode(arguments.string('png'));
|
|
|
|
|
} on FormatException {
|
|
|
|
|
throw ProfileRefusedException(reason: ProfileRefusal.avatarInvalid);
|
|
|
|
|
}
|
|
|
|
|
final profile = await register.setAvatar(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
ByteData.sublistView(png),
|
|
|
|
|
);
|
|
|
|
|
return {'profile': profile.toJson()};
|
|
|
|
|
case RegisterOperation.removeAvatar:
|
|
|
|
|
final profile = await register.removeAvatar(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
);
|
|
|
|
|
return {'profile': profile.toJson()};
|
|
|
|
|
case RegisterOperation.removeProfile:
|
|
|
|
|
await register.removeProfile(session, arguments.id('person'));
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.fixHandle:
|
|
|
|
|
await register.fixHandle(session, arguments.id('person'));
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.emailSignInOffered:
|
|
|
|
|
return {'offered': await register.emailSignInOffered(session)};
|
|
|
|
|
case RegisterOperation.sendSignInCode:
|
|
|
|
|
final request = await register.sendSignInCode(
|
|
|
|
|
session,
|
|
|
|
|
address: arguments.string('address'),
|
|
|
|
|
language: arguments.string('language'),
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
purpose: EmailCodePurpose.fromJson(arguments.string('purpose')),
|
2026-10-19 23:10:08 +02:00
|
|
|
network: arguments.string('network'),
|
|
|
|
|
);
|
|
|
|
|
return {'request': request.uuid};
|
|
|
|
|
case RegisterOperation.linkGitHub:
|
|
|
|
|
await register.linkGitHub(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
gitHubUserId: arguments.string('gitHubUserId'),
|
|
|
|
|
);
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.accountWithGitHub:
|
|
|
|
|
final account = await register.accountWithGitHub(
|
|
|
|
|
session,
|
|
|
|
|
arguments.string('gitHubUserId'),
|
|
|
|
|
);
|
|
|
|
|
return {'account': account?.uuid};
|
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
|
|
|
case RegisterOperation.simpleLoginOffered:
|
|
|
|
|
return {'offered': await register.simpleLoginOffered(session)};
|
|
|
|
|
case RegisterOperation.beginSimpleLogin:
|
|
|
|
|
final start = await register.beginSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
marker: arguments.string('marker'),
|
|
|
|
|
caller: arguments.optionalId('caller'),
|
|
|
|
|
network: arguments.string('network'),
|
|
|
|
|
);
|
|
|
|
|
return {'start': start.toJson()};
|
|
|
|
|
case RegisterOperation.completeSimpleLogin:
|
|
|
|
|
final proof = await register.completeSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: arguments.id('attempt'),
|
|
|
|
|
code: arguments.string('code'),
|
|
|
|
|
caller: arguments.optionalId('caller'),
|
|
|
|
|
);
|
|
|
|
|
return {'proof': proof.toJson()};
|
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
|
|
|
case RegisterOperation.checkSimpleLoginHandle:
|
|
|
|
|
final refusal = await register.checkSimpleLoginHandle(
|
|
|
|
|
session,
|
|
|
|
|
attempt: arguments.id('attempt'),
|
|
|
|
|
handle: arguments.string('handle'),
|
|
|
|
|
network: arguments.string('network'),
|
|
|
|
|
);
|
|
|
|
|
return {'refusal': refusal?.name};
|
|
|
|
|
case RegisterOperation.signUpWithSimpleLogin:
|
|
|
|
|
final account = await register.signUpWithSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: arguments.id('attempt'),
|
|
|
|
|
handle: arguments.string('handle'),
|
|
|
|
|
setup: arguments.model('setup', KeysSetup.fromJson),
|
|
|
|
|
);
|
|
|
|
|
return {'account': account.uuid};
|
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
|
|
|
case RegisterOperation.beginSimpleLoginMend:
|
|
|
|
|
final challenge = await register.beginSimpleLoginMend(
|
|
|
|
|
session,
|
|
|
|
|
attempt: arguments.id('attempt'),
|
|
|
|
|
handle: arguments.string('handle'),
|
|
|
|
|
network: arguments.string('network'),
|
|
|
|
|
);
|
|
|
|
|
return {'challenge': challenge.toJson()};
|
|
|
|
|
case RegisterOperation.mendSimpleLogin:
|
|
|
|
|
final proof = await register.mendSimpleLogin(
|
|
|
|
|
session,
|
|
|
|
|
attempt: arguments.id('attempt'),
|
|
|
|
|
proof: arguments.model('proof', KeysProof.fromJson),
|
|
|
|
|
);
|
|
|
|
|
return {'proof': proof.toJson()};
|
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
|
|
|
case RegisterOperation.beginPasswordKeyProof:
|
|
|
|
|
final challenge = await register.beginPasswordKeyProof(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
);
|
|
|
|
|
return {'challenge': challenge.toJson()};
|
|
|
|
|
case RegisterOperation.unlockKeys:
|
|
|
|
|
final keys = await register.unlockKeys(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
arguments.model('proof', KeysProof.fromJson),
|
|
|
|
|
);
|
|
|
|
|
return {'keys': keys.toJson()};
|
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
|
|
|
case RegisterOperation.setFirstKeys:
|
|
|
|
|
await register.setFirstKeys(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
attempt: arguments.id('attempt'),
|
|
|
|
|
setup: arguments.model('setup', KeysSetup.fromJson),
|
|
|
|
|
handle: arguments.optionalString('handle'),
|
|
|
|
|
);
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.beginPhraseKeyProof:
|
|
|
|
|
final challenge = await register.beginPhraseKeyProof(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
);
|
|
|
|
|
return {'challenge': challenge.toJson()};
|
|
|
|
|
case RegisterOperation.changeKeysPassword:
|
|
|
|
|
await register.changeKeysPassword(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
proof: arguments.model('proof', KeysOwnerProof.fromJson),
|
|
|
|
|
password: arguments.model('password', KeysPassword.fromJson),
|
|
|
|
|
keepSession: arguments.optionalId('keepSession'),
|
|
|
|
|
);
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.resetKeysPassword:
|
|
|
|
|
await register.resetKeysPassword(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
proof: arguments.model('proof', KeysOwnerProof.fromJson),
|
|
|
|
|
password: arguments.model('password', KeysPassword.fromJson),
|
|
|
|
|
keepSession: arguments.optionalId('keepSession'),
|
|
|
|
|
);
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.setKeysRecovery:
|
|
|
|
|
await register.setKeysRecovery(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
proof: arguments.model('proof', KeysOwnerProof.fromJson),
|
|
|
|
|
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
|
|
|
|
|
recoveryProofKey: arguments.bytes('recoveryProofKey'),
|
|
|
|
|
);
|
|
|
|
|
return const {};
|
2026-10-19 23:10:08 +02:00
|
|
|
case RegisterOperation.signInWaysOf:
|
|
|
|
|
final ways = await register.signInWaysOf(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
);
|
|
|
|
|
return {'ways': ways.toJson()};
|
2026-10-21 12:05:18 +02:00
|
|
|
case RegisterOperation.noteSessionEndedForProofs:
|
|
|
|
|
await register.noteSessionEndedForProofs(session, arguments.id('person'));
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.dismissSessionEndedWarning:
|
|
|
|
|
await register.dismissSessionEndedWarning(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
2026-10-21 13:32:33 +02:00
|
|
|
// When the member's session asking was made (the full reading's L1).
|
|
|
|
|
sessionMadeAt: arguments.optionalTime('sessionMadeAt'),
|
2026-10-21 12:05:18 +02:00
|
|
|
);
|
|
|
|
|
return const {};
|
2026-10-19 23:10:08 +02:00
|
|
|
case RegisterOperation.deleteAccount:
|
2026-10-20 21:59:42 +02:00
|
|
|
await register.deleteAccount(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
proof: arguments.has('proof')
|
|
|
|
|
? arguments.model('proof', DeletionProof.fromJson)
|
|
|
|
|
: null,
|
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
|
|
|
keysProof: arguments.has('keysProof')
|
|
|
|
|
? arguments.model('keysProof', KeysOwnerProof.fromJson)
|
|
|
|
|
: null,
|
2026-10-20 21:59:42 +02:00
|
|
|
);
|
2026-10-19 23:10:08 +02:00
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.knownAccounts:
|
|
|
|
|
final known = await register.knownAccounts(
|
|
|
|
|
session,
|
|
|
|
|
arguments.ids('people'),
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
'known': [for (final person in known) person.uuid],
|
|
|
|
|
};
|
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
|
|
|
case RegisterOperation.passwordSignInOffered:
|
|
|
|
|
return {'offered': await register.passwordSignInOffered(session)};
|
|
|
|
|
case RegisterOperation.setPassword:
|
|
|
|
|
await register.setPassword(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
setup: arguments.model('setup', PasswordSetup.fromJson),
|
|
|
|
|
request: arguments.optionalId('request'),
|
|
|
|
|
code: arguments.optionalString('code'),
|
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
|
|
|
// The member tells it of its own session (the fourth reading) and
|
|
|
|
|
// person (the full reading's M1): the register keeps no anonymous
|
|
|
|
|
// person of a member's.
|
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).
- An account with an address proves it anew: a code mailed to that
address for this (EmailCodePurpose.firstPassword, its own mail), never
the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
a GitHub sign-in (the session's method and creation time, where the
session lives; members tell the register), refused signInAgain before
any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
they add.
The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:31:22 +02:00
|
|
|
freshGitHubSignIn: arguments.flag('freshGitHubSignIn'),
|
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: arguments.flag('anonymousHere'),
|
2026-10-21 13:34:00 +02:00
|
|
|
keepSession: arguments.optionalId('keepSession'),
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
);
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.beginPasswordSignIn:
|
|
|
|
|
final challenge = await register.beginPasswordSignIn(
|
|
|
|
|
session,
|
|
|
|
|
address: arguments.string('address'),
|
|
|
|
|
network: arguments.string('network'),
|
|
|
|
|
);
|
|
|
|
|
return {'challenge': challenge.toJson()};
|
|
|
|
|
case RegisterOperation.completePasswordSignIn:
|
|
|
|
|
final proof = await register.completePasswordSignIn(
|
|
|
|
|
session,
|
|
|
|
|
challenge: arguments.id('challenge'),
|
|
|
|
|
clientValue: arguments.bytes('clientValue'),
|
|
|
|
|
clientProof: arguments.bytes('clientProof'),
|
|
|
|
|
totpCode: arguments.optionalString('totpCode'),
|
|
|
|
|
recoveryCode: arguments.optionalString('recoveryCode'),
|
2026-10-20 14:41:16 +02:00
|
|
|
request: arguments.optionalId('request'),
|
|
|
|
|
code: arguments.optionalString('code'),
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrl: arguments.string('instanceUrl'),
|
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: arguments.string('address'),
|
|
|
|
|
language: arguments.string('language'),
|
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
|
|
|
);
|
|
|
|
|
return {'proof': proof.toJson()};
|
2026-10-20 14:41:16 +02:00
|
|
|
case RegisterOperation.signUpWithCode:
|
|
|
|
|
final account = await register.signUpWithCode(
|
|
|
|
|
session,
|
|
|
|
|
request: arguments.id('request'),
|
|
|
|
|
code: arguments.string('code'),
|
|
|
|
|
setup: arguments.model('setup', PasswordSetup.fromJson),
|
|
|
|
|
);
|
|
|
|
|
return {'account': account.uuid};
|
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
|
|
|
case RegisterOperation.enrolAuthenticator:
|
|
|
|
|
final enrolment = await register.enrolAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
name: arguments.string('name'),
|
|
|
|
|
label: arguments.string('label'),
|
|
|
|
|
);
|
|
|
|
|
return {'enrolment': enrolment.toJson()};
|
|
|
|
|
case RegisterOperation.confirmAuthenticator:
|
|
|
|
|
final codes = await register.confirmAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
authenticator: arguments.id('authenticator'),
|
|
|
|
|
code: arguments.string('code'),
|
fix(auth)!: a session alone never gets a code checked
The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.
A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.
The app follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
|
|
|
proof: arguments.has('proof')
|
|
|
|
|
? arguments.model('proof', OwnerProof.fromJson)
|
|
|
|
|
: 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
|
|
|
);
|
|
|
|
|
return {'recoveryCodes': codes};
|
|
|
|
|
case RegisterOperation.authenticatorsOf:
|
|
|
|
|
final authenticators = await register.authenticatorsOf(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
'authenticators': [
|
|
|
|
|
for (final authenticator in authenticators) authenticator.toJson(),
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
case RegisterOperation.removeAuthenticator:
|
|
|
|
|
await register.removeAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
authenticator: arguments.id('authenticator'),
|
fix(auth)!: a session alone never gets a code checked
The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.
A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.
The app follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
|
|
|
proof: arguments.has('proof')
|
|
|
|
|
? arguments.model('proof', OwnerProof.fromJson)
|
|
|
|
|
: 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
|
|
|
);
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.newRecoveryCodes:
|
|
|
|
|
final codes = await register.newRecoveryCodes(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
fix(auth)!: a session alone never gets a code checked
The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.
A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.
The app follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
|
|
|
proof: arguments.model('proof', OwnerProof.fromJson),
|
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
|
|
|
);
|
|
|
|
|
return {'recoveryCodes': codes};
|
|
|
|
|
case RegisterOperation.resetAuthenticator:
|
|
|
|
|
final pending = await register.resetAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
challenge: arguments.id('challenge'),
|
|
|
|
|
clientValue: arguments.bytes('clientValue'),
|
|
|
|
|
clientProof: arguments.bytes('clientProof'),
|
|
|
|
|
address: arguments.string('address'),
|
|
|
|
|
language: arguments.string('language'),
|
|
|
|
|
request: arguments.optionalId('request'),
|
|
|
|
|
code: arguments.optionalString('code'),
|
|
|
|
|
keyChallenge: arguments.optionalId('keyChallenge'),
|
|
|
|
|
signature: arguments.optionalBytes('signature'),
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrl: arguments.string('instanceUrl'),
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
);
|
|
|
|
|
return {'pending': pending.toJson()};
|
|
|
|
|
case RegisterOperation.beginKeyProof:
|
|
|
|
|
final challenge = await register.beginKeyProof(
|
|
|
|
|
session,
|
|
|
|
|
address: arguments.string('address'),
|
|
|
|
|
network: arguments.string('network'),
|
|
|
|
|
);
|
|
|
|
|
return {'challenge': challenge.toJson()};
|
|
|
|
|
case RegisterOperation.resetPasswordWithPhrase:
|
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 ended = await register.resetPasswordWithPhrase(
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
session,
|
|
|
|
|
challenge: arguments.id('challenge'),
|
|
|
|
|
signature: arguments.bytes('signature'),
|
|
|
|
|
password: arguments.model('password', NewPassword.fromJson),
|
|
|
|
|
totpCode: arguments.optionalString('totpCode'),
|
|
|
|
|
recoveryCode: arguments.optionalString('recoveryCode'),
|
fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:59:32 +02:00
|
|
|
instanceUrl: arguments.string('instanceUrl'),
|
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: arguments.string('address'),
|
|
|
|
|
language: arguments.string('language'),
|
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
|
|
|
return _sessionsEnded(ended);
|
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
|
|
|
case RegisterOperation.startAccountReset:
|
|
|
|
|
final pending = await register.startAccountReset(
|
|
|
|
|
session,
|
|
|
|
|
address: arguments.string('address'),
|
|
|
|
|
language: arguments.string('language'),
|
|
|
|
|
request: arguments.id('request'),
|
|
|
|
|
code: arguments.string('code'),
|
|
|
|
|
);
|
|
|
|
|
return {'pending': pending.toJson()};
|
|
|
|
|
case RegisterOperation.completeAccountReset:
|
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 ended = await register.completeAccountReset(
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
session,
|
|
|
|
|
address: arguments.string('address'),
|
|
|
|
|
request: arguments.id('request'),
|
|
|
|
|
code: arguments.string('code'),
|
|
|
|
|
setup: arguments.model('setup', PasswordSetup.fromJson),
|
|
|
|
|
);
|
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
|
|
|
return _sessionsEnded(ended);
|
|
|
|
|
case RegisterOperation.beginPasswordChange:
|
|
|
|
|
final challenge = await register.beginPasswordChange(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
);
|
|
|
|
|
return {'challenge': challenge.toJson()};
|
|
|
|
|
case RegisterOperation.changePassword:
|
|
|
|
|
await register.changePassword(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
fix(auth)!: a session alone never gets a code checked
The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.
A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.
The app follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
|
|
|
proof: arguments.model('proof', OwnerProof.fromJson),
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
password: arguments.model('password', NewPassword.fromJson),
|
|
|
|
|
keepSession: arguments.optionalId('keepSession'),
|
|
|
|
|
);
|
|
|
|
|
return const {};
|
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
|
|
|
case RegisterOperation.callOffResets:
|
|
|
|
|
await register.callOffResets(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
proof: arguments.model('proof', OwnerProof.fromJson),
|
|
|
|
|
);
|
|
|
|
|
return const {};
|
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
|
|
|
case RegisterOperation.setRecoveryKeys:
|
|
|
|
|
await register.setRecoveryKeys(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
fix(auth)!: a session alone never gets a code checked
The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.
A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.
The app follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 10:33:25 +02:00
|
|
|
proof: arguments.model('proof', OwnerProof.fromJson),
|
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: arguments.model('recoveryKeys', WrappedKeys.fromJson),
|
fix(auth)!: the recovery phrase is proved by a key of its own
A key proof was a signature by the account's signing key, which every
signed-in device keeps in its keyring: whoever could read one, with a
code from its authenticator, set a new password without the password or
the phrase (the second reading's H1). The phrase now proves itself with
an Ed25519 key derived from its bits alone (halkyon_keys'
recoveryProofKeysOf, KDF context hlkrprf1, a sibling of the recovery
key), whose public half every setup, new phrase and account reset sends
and the register keeps (account_password.recoveryProofKey); the device
derives the secret half only while the phrase is typed. An account
without one (none exist outside development) proves no phrase until it
sets a new one; decoys check against a decoy proof key alike.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_password" ADD COLUMN "recoveryProofKey" bytea;
nullable, so rows made before keep working and no data is touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:03:15 +02:00
|
|
|
recoveryProofKey: arguments.bytes('recoveryProofKey'),
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
);
|
|
|
|
|
return const {};
|
2026-10-19 23:10:08 +02:00
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/// [ended] as the register answers it.
|
|
|
|
|
Map<String, Object?> _sessionsEnded(AccountSessionsEnded ended) => {
|
|
|
|
|
'account': ended.account.uuid,
|
|
|
|
|
'endedBefore': ended.endedBefore.toUtc().toIso8601String(),
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
/// A member answering the register that [body]'s account is gone
|
|
|
|
|
/// ([accountGonePath]): that person's data here is deleted, here alone (the
|
|
|
|
|
/// register deleted the account), and the register told so. On the register
|
|
|
|
|
/// itself, nothing is answered.
|
|
|
|
|
Future<InstanceAnswer> answerRegister(
|
|
|
|
|
Session session,
|
|
|
|
|
String body, {
|
|
|
|
|
required String? authorization,
|
|
|
|
|
}) async {
|
|
|
|
|
final pod = session.serverpod;
|
|
|
|
|
if (!_carriesSecret(pod, authorization)) {
|
|
|
|
|
return _unauthorized;
|
|
|
|
|
}
|
|
|
|
|
if (HalkyonBase.maybeOf(pod)?.accounts is! MemberOfRegister) {
|
|
|
|
|
return _notHere;
|
|
|
|
|
}
|
|
|
|
|
final arguments = _arguments(body);
|
|
|
|
|
if (arguments == null) {
|
|
|
|
|
return _unreadable;
|
|
|
|
|
}
|
|
|
|
|
final UuidValue account;
|
|
|
|
|
try {
|
|
|
|
|
account = _Arguments(arguments).id('account');
|
|
|
|
|
} on FormatException {
|
|
|
|
|
return _unreadable;
|
|
|
|
|
}
|
|
|
|
|
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = $1
|
|
|
|
|
if (await AuthUser.db.findById(session, account) != null) {
|
|
|
|
|
await deletePerson(session, account);
|
|
|
|
|
await session.messages.authenticationRevoked(
|
|
|
|
|
account.uuid,
|
|
|
|
|
RevokedAuthenticationUser(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (status: 200, body: const <String, Object?>{});
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/// A member answering the register that [body]'s account's sessions ended
|
|
|
|
|
/// ([sessionsEndedPath], ADR 0017): those here created before the moment it
|
2026-10-21 17:45:43 +02:00
|
|
|
/// names end, but the one it keeps and the one made here for that moment
|
|
|
|
|
/// (`sessionMadeAfter`), and the register is told so. On the register
|
|
|
|
|
/// itself, nothing is answered.
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
Future<InstanceAnswer> answerSessionsEnded(
|
|
|
|
|
Session session,
|
|
|
|
|
String body, {
|
|
|
|
|
required String? authorization,
|
|
|
|
|
}) async {
|
|
|
|
|
final pod = session.serverpod;
|
|
|
|
|
if (!_carriesSecret(pod, authorization)) {
|
|
|
|
|
return _unauthorized;
|
|
|
|
|
}
|
|
|
|
|
if (HalkyonBase.maybeOf(pod)?.accounts is! MemberOfRegister) {
|
|
|
|
|
return _notHere;
|
|
|
|
|
}
|
|
|
|
|
final arguments = _arguments(body);
|
|
|
|
|
if (arguments == null) {
|
|
|
|
|
return _unreadable;
|
|
|
|
|
}
|
|
|
|
|
final UuidValue account;
|
|
|
|
|
final DateTime endedBefore;
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
final DateTime? sentAt;
|
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 UuidValue? keepSession;
|
|
|
|
|
try {
|
|
|
|
|
final read = _Arguments(arguments);
|
|
|
|
|
account = read.id('account');
|
|
|
|
|
endedBefore = DateTime.parse(read.string('endedBefore')).toUtc();
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
sentAt = read.optionalTime('sentAt');
|
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 = read.optionalId('keepSession');
|
|
|
|
|
} on FormatException {
|
|
|
|
|
return _unreadable;
|
|
|
|
|
}
|
|
|
|
|
await endSessionsHere(
|
|
|
|
|
session,
|
|
|
|
|
account,
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
endedBefore: _byThisClock(endedBefore, sentAt: sentAt),
|
2026-10-21 17:45:43 +02:00
|
|
|
keepSessions: {
|
|
|
|
|
?keepSession,
|
|
|
|
|
?sessionMadeAfter(account, endedBefore),
|
|
|
|
|
},
|
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
|
|
|
);
|
|
|
|
|
return (status: 200, body: const <String, Object?>{});
|
|
|
|
|
}
|
|
|
|
|
|
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.
issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 15:56:43 +02:00
|
|
|
/// The register's moment [endedBefore] by this server's clock: as long
|
|
|
|
|
/// before now as it was before the register sent it ([sentAt], by the
|
|
|
|
|
/// register's clock), so the clocks' offset drops out (the reading of the
|
|
|
|
|
/// fixes' C1; one way's travel makes it a moment later, ending a session
|
|
|
|
|
/// made in that instant too). A moment sent before it was taken (a clock set
|
|
|
|
|
/// back) is taken as now; without [sentAt] (an older register), as sent.
|
|
|
|
|
DateTime _byThisClock(DateTime endedBefore, {required DateTime? sentAt}) {
|
|
|
|
|
if (sentAt == null) {
|
|
|
|
|
return endedBefore;
|
|
|
|
|
}
|
|
|
|
|
final age = sentAt.difference(endedBefore);
|
|
|
|
|
return clock.now().toUtc().subtract(age.isNegative ? Duration.zero : age);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
/// A request's arguments, each read as what it must be; a [FormatException]
|
|
|
|
|
/// for anything else.
|
|
|
|
|
extension type const _Arguments(Map<String, Object?> _map) {
|
|
|
|
|
String string(String name) => switch (_map[name]) {
|
|
|
|
|
final String value => value,
|
|
|
|
|
_ => throw FormatException('$name is no string'),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
List<String> strings(String name) => switch (_map[name]) {
|
|
|
|
|
final List<Object?> values when values.every((value) => value is String) =>
|
|
|
|
|
values.cast<String>(),
|
|
|
|
|
_ => throw FormatException('$name is no list of strings'),
|
|
|
|
|
};
|
|
|
|
|
|
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
|
|
|
String? optionalString(String name) => switch (_map[name]) {
|
|
|
|
|
null => null,
|
|
|
|
|
final String value => value,
|
|
|
|
|
_ => throw FormatException('$name is no string'),
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
UuidValue id(String name) => UuidValue.withValidation(string(name));
|
|
|
|
|
|
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
|
|
|
UuidValue? optionalId(String name) => switch (optionalString(name)) {
|
|
|
|
|
final value? => UuidValue.withValidation(value),
|
|
|
|
|
null => null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ByteData bytes(String name) => ByteData.sublistView(
|
|
|
|
|
base64Decode(string(name)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
ByteData? optionalBytes(String name) =>
|
|
|
|
|
optionalString(name) == null ? null : bytes(name);
|
|
|
|
|
|
2026-10-21 13:32:33 +02:00
|
|
|
/// The moment [name] (ISO 8601) in UTC; null where it is missing.
|
|
|
|
|
DateTime? optionalTime(String name) => switch (optionalString(name)) {
|
|
|
|
|
final value? => DateTime.parse(value).toUtc(),
|
|
|
|
|
null => null,
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-20 21:59:42 +02:00
|
|
|
/// Whether the argument [name] holds anything (not null).
|
|
|
|
|
bool has(String name) => _map[name] != null;
|
|
|
|
|
|
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).
- An account with an address proves it anew: a code mailed to that
address for this (EmailCodePurpose.firstPassword, its own mail), never
the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
a GitHub sign-in (the session's method and creation time, where the
session lives; members tell the register), refused signInAgain before
any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
they add.
The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:31:22 +02:00
|
|
|
/// The flag [name]: false where it is missing.
|
|
|
|
|
bool flag(String name) => switch (_map[name]) {
|
|
|
|
|
null => false,
|
|
|
|
|
final bool value => value,
|
|
|
|
|
_ => throw FormatException('$name is no flag'),
|
|
|
|
|
};
|
|
|
|
|
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
/// The model [read] makes of the object [name]; a [FormatException] for
|
|
|
|
|
/// what isn't one.
|
|
|
|
|
T model<T>(String name, T Function(Map<String, dynamic> json) read) {
|
|
|
|
|
final json = _map[name];
|
|
|
|
|
if (json is! Map<String, Object?>) {
|
|
|
|
|
throw FormatException('$name is no object');
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
return read(json);
|
|
|
|
|
} on FormatException {
|
|
|
|
|
rethrow;
|
|
|
|
|
} on Object catch (error) {
|
|
|
|
|
// A field missing or of another type throws a TypeError: the request
|
|
|
|
|
// is unreadable all the same.
|
|
|
|
|
throw FormatException('$name: ${error.runtimeType}');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
Set<UuidValue> ids(String name) => {
|
|
|
|
|
for (final value in strings(name)) UuidValue.withValidation(value),
|
|
|
|
|
};
|
|
|
|
|
}
|