halkyon/packages/halkyon_base_server/lib/src/register/instance_api.dart
Tizian 「ティツィアーン」 e5b9578916
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-24 09:17:49 +02:00

692 lines
25 KiB
Dart

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()};
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};
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()};
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()};
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,
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],
};
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'),
);
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'),
);
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};
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'),
proof: arguments.has('proof')
? arguments.model('proof', OwnerProof.fromJson)
: null,
);
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'),
proof: arguments.has('proof')
? arguments.model('proof', OwnerProof.fromJson)
: null,
);
return const {};
case RegisterOperation.newRecoveryCodes:
final codes = await register.newRecoveryCodes(
session,
person: arguments.id('person'),
proof: arguments.model('proof', OwnerProof.fromJson),
);
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'),
);
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(
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'),
);
return _sessionsEnded(ended);
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(
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'),
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'),
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'),
};
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));
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'),
};
/// 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),
};
}