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

111 lines
4.1 KiB
Dart
Raw Normal View History

import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:serverpod/serverpod.dart';
/// How this server takes part in its instance's accounts (ADR 0016).
sealed class AccountsMode {
const AccountsMode();
/// The mode the operator's environment names: a member of the register at
/// `ACCOUNT_REGISTER_URL` where it is set, else the register, telling the
/// members at `INSTANCE_MEMBERS` (addresses separated by commas or spaces)
/// and at each `INSTANCE_MEMBER_<NAME>` (one address each, in their names'
/// order: a service's compose file names itself, so that several such files
/// add up where one variable would be replaced by the last) when an account
/// goes, each address once. Empty values count as unset: Compose passes
/// unset variables on as empty strings.
factory AccountsMode.fromEnvironment([Map<String, String>? environment]) {
final env = environment ?? Platform.environment;
if (_address(env['ACCOUNT_REGISTER_URL'], 'ACCOUNT_REGISTER_URL')
case final register?) {
return MemberOfRegister(register);
}
final named = [
for (final variable in env.keys)
if (variable.startsWith(_memberPrefix) &&
variable.length > _memberPrefix.length)
variable,
]..sort();
return HoldsRegister(
members: {
for (final member in (env['INSTANCE_MEMBERS'] ?? '').split(
RegExp(r'[\s,]+'),
))
?_address(member, 'INSTANCE_MEMBERS'),
for (final variable in named) ?_address(env[variable], variable),
}.toList(),
);
}
static const _memberPrefix = 'INSTANCE_MEMBER_';
static Uri? _address(String? value, String variable) {
final trimmed = value?.trim() ?? '';
if (trimmed.isEmpty) {
return null;
}
final uri = Uri.tryParse(trimmed);
if (uri == null ||
!(uri.isScheme('http') || uri.isScheme('https')) ||
uri.host.isEmpty) {
throw ArgumentError.value(trimmed, variable, 'is no HTTP(S) address');
}
return uri;
}
}
/// This server holds the instance's register: its database keeps the
/// accounts, their ways in, handles and profiles. With a single service, it
/// is its own register and has no [members].
final class HoldsRegister extends AccountsMode {
const HoldsRegister({this.members = const []});
/// The web servers of the instance's other services, told when an account
/// goes so they delete that person's data too.
final List<Uri> members;
}
/// This server is a member: it asks the instance's register at [register]
/// (its web server) for everything about accounts, and keeps only its own
/// data of each person, under the register's account ID.
final class MemberOfRegister extends AccountsMode {
const MemberOfRegister(this.register);
/// The register's web server, as this server reaches it (its address on
/// the instance's own network when both run on one host).
final Uri register;
}
/// The instance's shared secret (`instanceSecret` in the passwords;
/// `INSTANCE_SECRET` in a deploy's `.env`), which its services send with
/// every call between them; null without one, and then no such call is made
/// or taken.
String? instanceSecretOf(Serverpod pod) =>
_secretOf[pod] ??
switch (pod.getPassword('instanceSecret')) {
final secret? when secret.isNotEmpty => secret,
_ => null,
};
/// Puts [secret] in place of [pod]'s passwords' `instanceSecret`, or with
/// null the passwords' back: tests do, as the passwords of a test run hold
/// none.
void useInstanceSecret(Serverpod pod, String? secret) =>
_secretOf[pod] = secret;
final _secretOf = Expando<String>('Instance secret');
/// Whether [presented] is [secret], compared in constant time: both hashed,
/// so neither their length nor where they first differ shows in how long it
/// takes.
bool isInstanceSecret(String presented, String secret) {
final a = sha256.convert(utf8.encode(presented)).bytes;
final b = sha256.convert(utf8.encode(secret)).bytes;
var difference = 0;
for (var i = 0; i < a.length; i++) {
difference |= a[i] ^ b[i];
}
return difference == 0;
}