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

532 lines
19 KiB
Dart
Raw Normal View History

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
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'),
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.signInWaysOf:
final ways = await register.signInWaysOf(
session,
arguments.id('person'),
);
return {'ways': ways.toJson()};
case RegisterOperation.deleteAccount:
await register.deleteAccount(
session,
arguments.id('person'),
proof: arguments.has('proof')
? arguments.model('proof', DeletionProof.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'),
);
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'),
host: arguments.string('host'),
fix(auth): codes wait after ten wrong in a row, and the owner is told Whoever knew the password could guess codes at 10 an hour forever, unnoticed, each guess checked against every authenticator (the second reading's M4). Every code an account's second factor refuses after a right password (sign-in, a phrase's reset, a change) or from a signed-in device (removing an authenticator, new recovery codes, the code from one confirmed before) now counts, per account, from every network; after every tenth in a row no code is taken (codesWait, saying until when), an hour, then twice as long each time, a day at the most (longestCodeWait, second_factor_waits.dart): at a day, ten guesses a day still have about a 1 % chance a year against one authenticator, so the first wait mails the owner that someone knows their password. The instance keeps no address, so sign-in and the phrase's reset now send the address the device typed (and its language), used only for that mail after its hash is checked against the account's. A code accepted clears it all. Migration (module, Learn's and Todo's servers, Learn's device schema): CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY, "authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits" bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT NULL) with a unique index on "authUserId": a new table, nothing touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:47:38 +02:00
address: arguments.optionalString('address'),
language: arguments.optionalString('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'),
existingCode: arguments.optionalString('existingCode'),
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'),
code: arguments.optionalString('code'),
);
return const {};
case RegisterOperation.newRecoveryCodes:
final codes = await register.newRecoveryCodes(
session,
person: arguments.id('person'),
code: arguments.string('code'),
);
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'),
host: arguments.string('host'),
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'),
host: arguments.string('host'),
fix(auth): codes wait after ten wrong in a row, and the owner is told Whoever knew the password could guess codes at 10 an hour forever, unnoticed, each guess checked against every authenticator (the second reading's M4). Every code an account's second factor refuses after a right password (sign-in, a phrase's reset, a change) or from a signed-in device (removing an authenticator, new recovery codes, the code from one confirmed before) now counts, per account, from every network; after every tenth in a row no code is taken (codesWait, saying until when), an hour, then twice as long each time, a day at the most (longestCodeWait, second_factor_waits.dart): at a day, ten guesses a day still have about a 1 % chance a year against one authenticator, so the first wait mails the owner that someone knows their password. The instance keeps no address, so sign-in and the phrase's reset now send the address the device typed (and its language), used only for that mail after its hash is checked against the account's. A code accepted clears it all. Migration (module, Learn's and Todo's servers, Learn's device schema): CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY, "authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits" bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT NULL) with a unique index on "authUserId": a new table, nothing touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:47:38 +02:00
address: arguments.optionalString('address'),
language: arguments.optionalString('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'),
challenge: arguments.id('challenge'),
clientValue: arguments.bytes('clientValue'),
clientProof: arguments.bytes('clientProof'),
totpCode: arguments.optionalString('totpCode'),
recoveryCode: arguments.optionalString('recoveryCode'),
password: arguments.model('password', NewPassword.fromJson),
keepSession: arguments.optionalId('keepSession'),
host: arguments.string('host'),
);
return const {};
case RegisterOperation.setRecoveryKeys:
await register.setRecoveryKeys(
session,
person: arguments.id('person'),
challenge: arguments.id('challenge'),
clientValue: arguments.bytes('clientValue'),
clientProof: arguments.bytes('clientProof'),
totpCode: arguments.optionalString('totpCode'),
recoveryCode: arguments.optionalString('recoveryCode'),
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
recoveryProofKey: arguments.bytes('recoveryProofKey'),
host: arguments.string('host'),
);
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 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 UuidValue? keepSession;
try {
final read = _Arguments(arguments);
account = read.id('account');
endedBefore = DateTime.parse(read.string('endedBefore')).toUtc();
keepSession = read.optionalId('keepSession');
} on FormatException {
return _unreadable;
}
await endSessionsHere(
session,
account,
endedBefore: endedBefore,
keepSession: keepSession,
);
return (status: 200, body: const <String, Object?>{});
}
/// 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);
/// Whether the argument [name] holds anything (not null).
bool has(String name) => _map[name] != 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
/// 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),
};
}