halkyon/packages/halkyon_base_server/lib/src/register/instance_api.dart

692 lines
25 KiB
Dart
Raw Normal View History

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
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';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
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'),
purpose: EmailCodePurpose.fromJson(arguments.string('purpose')),
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};
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 {};
case RegisterOperation.signInWaysOf:
final ways = await register.signInWaysOf(
session,
arguments.id('person'),
);
return {'ways': ways.toJson()};
case RegisterOperation.noteSessionEndedForProofs:
await register.noteSessionEndedForProofs(session, arguments.id('person'));
return const {};
case RegisterOperation.dismissSessionEndedWarning:
await register.dismissSessionEndedWarning(
session,
arguments.id('person'),
// When the member's session asking was made (the full reading's L1).
sessionMadeAt: arguments.optionalTime('sessionMadeAt'),
);
return const {};
case RegisterOperation.deleteAccount:
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,
);
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'),
// 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.
freshGitHubSignIn: arguments.flag('freshGitHubSignIn'),
anonymousHere: arguments.flag('anonymousHere'),
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'),
request: arguments.optionalId('request'),
code: arguments.optionalString('code'),
instanceUrl: arguments.string('instanceUrl'),
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()};
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'),
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:
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'),
instanceUrl: arguments.string('instanceUrl'),
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 _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:
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),
);
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),
password: arguments.model('password', NewPassword.fromJson),
keepSession: arguments.optionalId('keepSession'),
);
return const {};
case RegisterOperation.callOffResets:
await register.callOffResets(
session,
person: arguments.id('person'),
proof: arguments.model('proof', OwnerProof.fromJson),
);
return const {};
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),
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
recoveryProofKey: arguments.bytes('recoveryProofKey'),
);
return const {};
}
return null;
}
/// [ended] as the register answers it.
Map<String, Object?> _sessionsEnded(AccountSessionsEnded ended) => {
'account': ended.account.uuid,
'endedBefore': ended.endedBefore.toUtc().toIso8601String(),
};
/// 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?>{});
}
/// A member answering the register that [body]'s account's sessions ended
/// ([sessionsEndedPath], ADR 0017): those here created before the moment it
/// 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.
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;
final DateTime? sentAt;
final UuidValue? keepSession;
try {
final read = _Arguments(arguments);
account = read.id('account');
endedBefore = DateTime.parse(read.string('endedBefore')).toUtc();
sentAt = read.optionalTime('sentAt');
keepSession = read.optionalId('keepSession');
} on FormatException {
return _unreadable;
}
await endSessionsHere(
session,
account,
endedBefore: _byThisClock(endedBefore, sentAt: sentAt),
keepSessions: {
?keepSession,
?sessionMadeAfter(account, endedBefore),
},
);
return (status: 200, body: const <String, Object?>{});
}
/// 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);
}
/// 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'),
};
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);
/// 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,
};
/// Whether the argument [name] holds anything (not null).
bool has(String name) => _map[name] != null;
/// 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}');
}
}
Set<UuidValue> ids(String name) => {
for (final value in strings(name)) UuidValue.withValidation(value),
};
}