halkyon/packages/halkyon_app/lib/src/testing/fake_simplelogin_gateway.dart
Tizian 「ティツィアーン」 e87b156676
feat(app): signing in with SimpleLogin on the device, its data layer (ADR 0020)
SimpleLoginRepository signs in with SimpleLogin in the browser
(OidcBrowser: the app's scheme on Android, iOS and macOS, the app's port
on the loopback on Linux and Windows, D9), then makes the account's keys
and signs someone new up, opens an account's keys with the password, gives
an account of before its first keys, or links a new SimpleLogin account in
place of a lost one with the recovery phrase (D7), which opens the keys on
the device first: a phrase that doesn't fit sends no proof, and no
password is asked after. An anonymous person adding SimpleLogin keeps the
keys their device holds. Nothing is the device's until `enter`.

SimpleLoginGateway calls halkyon_base's `simpleLogin` and `keys`
endpoints (ServerpodSimpleLoginGateway), with a fake for tests, as does
FakeOidcBrowser. The calls made on clients of their own (signed out, or as
a session not yet the device's) move from the password gateway into
BaseCalls, which both share.

Fixes BaseProtocol, the client of halkyon_base alone, which didn't read
halkyon_oidc's classes: every refusal of SimpleLogin's sign-in on a
signed-out call would have reached the app as a failure. OidcBrowser keeps
its loopback private, so that a fake can implement it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 09:17:50 +02:00

363 lines
11 KiB
Dart

import 'dart:typed_data';
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
import 'package:halkyon_app/src/simplelogin/data/simplelogin_gateway.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_arrival.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_failed.dart';
import 'package:halkyon_app/src/testing/fake_account_crypto.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart' show OidcStart;
/// An account as [FakeSimpleLoginGateway] keeps it.
final class FakeSimpleLoginAccount({
/// Its handle; null for an account of before, until its keys come.
var String? handle,
/// Its keys; null for an account of before, until they come.
var protocol.KeysSetup? keys,
});
/// A [SimpleLoginGateway] without a server, as an instance's register
/// behaves in what an app sees (ADR 0020): SimpleLogin's people by the
/// subject a code names (`code for <subject>`, [codeFor]), accounts by the
/// person's ID ([accounts]), proofs checked by [FakeAccountCrypto]'s
/// fakes. The signed-in person is [_auth]'s. [failing] makes every call
/// fail.
class FakeSimpleLoginGateway(final AuthGateway _auth)
implements SimpleLoginGateway {
@override
String get instanceUrl => 'https://halkyon.example/';
/// Whether the instance offers SimpleLogin.
bool isOffered = true;
/// The accounts, by their person's ID.
final accounts = <String, FakeSimpleLoginAccount>{};
/// Whose each SimpleLogin subject is, by subject.
final linked = <String, String>{};
/// The calls made, by name, in order.
final calls = <String>[];
/// The ways back sign-ins began with, in order.
final begunBack = <protocol.SimpleLoginReturn>[];
/// Thrown by every call while set.
Exception? failing;
/// The handle suggested for someone new.
String? suggestedHandle = 'anna_mueller';
/// The code SimpleLogin hands back for [subject].
static String codeFor(String subject) => 'code for $subject';
final _begun = <protocol.UuidValue, ({bool link})>{};
final _waiting = <protocol.UuidValue, String>{};
final _signedIn = <protocol.UuidValue, String>{};
final _challenges = <protocol.UuidValue, String>{};
var _ids = 0;
var _sessions = 0;
/// Keys no phrase opens, as the register's decoy for a handle nobody has.
static final _decoy = UnlockedAccountKeys(
encryptionPublicKey: Uint8List(32),
encryptionSecretKey: Uint8List(32),
signingPublicKey: Uint8List(32),
signingSecretKey: Uint8List(64),
);
/// Adds an account for [person] with [handle] and [keys], which
/// SimpleLogin's [subject] signs in to.
void addAccount(
String person, {
required String subject,
String? handle,
protocol.KeysSetup? keys,
}) {
accounts[person] = FakeSimpleLoginAccount(handle: handle, keys: keys);
linked[subject] = person;
}
protocol.UuidValue _newId() => protocol.UuidValue.fromString(
'00000000-0000-4000-8000-${(++_ids).toString().padLeft(12, '0')}',
);
Future<void> _call(String name) async {
calls.add(name);
if (failing case final error?) {
throw error;
}
}
String? get _person => switch (_auth.status) {
SignedIn(:final authUserId) => authUserId,
SignedOut() => null,
};
SessionGrant _grantFor(String person) =>
SessionGrant(person, 'session-${++_sessions}');
/// Whose [grant] is, or the device's person's.
String _owner(SessionGrant? grant) =>
grant?.authUserId ??
_person ??
(throw const SimpleLoginFailed(SimpleLoginProblem.failed));
@override
Future<bool> offered() async {
await _call('offered');
return isOffered;
}
@override
Future<OidcStart> begin(
protocol.SimpleLoginReturn back, {
bool link = false,
}) async {
await _call('begin');
if (!isOffered) {
throw const SimpleLoginFailed(SimpleLoginProblem.unavailable);
}
begunBack.add(back);
final attempt = _newId();
_begun[attempt] = (link: link);
final state = 'test-${back.name}.$attempt';
return OidcStart(
attempt: attempt,
authorizationUrl: Uri.parse(
'https://app.simplelogin.io/oauth2/authorize?state=$state',
),
state: state,
);
}
@override
Future<SimpleLoginArrival> complete(
protocol.UuidValue attempt,
String code, {
bool link = false,
}) async {
await _call('complete');
final begun = _begun.remove(attempt);
if (begun == null) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
if (begun.link != link) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
if (!code.startsWith('code for ')) {
throw const SimpleLoginFailed(SimpleLoginProblem.rejected);
}
final subject = code.substring('code for '.length);
final owner = linked[subject];
if (link) {
final person = _owner(null);
if (owner != null && owner != person) {
throw const SimpleLoginFailed(SimpleLoginProblem.takenByAnotherPerson);
}
if (owner == null && linked.containsValue(person)) {
throw const SimpleLoginFailed(SimpleLoginProblem.otherIdentityLinked);
}
linked[subject] = person;
final account = accounts.putIfAbsent(person, FakeSimpleLoginAccount.new);
_signedIn[attempt] = person;
return SimpleLoginAccount(
attempt: attempt,
grant: null,
hasKeys: account.keys != null,
handle: account.handle,
);
}
if (owner == null) {
_waiting[attempt] = subject;
return SimpleLoginNewcomer(
attempt: attempt,
suggestedHandle: suggestedHandle,
);
}
final account = accounts[owner]!;
_signedIn[attempt] = owner;
return SimpleLoginAccount(
attempt: attempt,
grant: _grantFor(owner),
hasKeys: account.keys != null,
handle: account.handle,
);
}
SimpleLoginProblem? _handleProblem(String handle) {
if (!RegExp(r'^[a-z0-9_]{3,30}$').hasMatch(handle)) {
return SimpleLoginProblem.handleInvalid;
}
if (handle == 'admin') {
return SimpleLoginProblem.handleReserved;
}
if (accounts.values.any((account) => account.handle == handle)) {
return SimpleLoginProblem.handleTaken;
}
return null;
}
@override
Future<SimpleLoginProblem?> checkHandle(
protocol.UuidValue attempt,
String handle,
) async {
await _call('checkHandle');
if (!_waiting.containsKey(attempt)) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
return _handleProblem(handle);
}
@override
Future<SessionGrant> signUp(
protocol.UuidValue attempt,
String handle,
protocol.KeysSetup setup,
) async {
await _call('signUp');
final subject = _waiting[attempt];
if (subject == null || _person != null) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
if (_handleProblem(handle) case final problem?) {
throw SimpleLoginFailed(problem);
}
_waiting.remove(attempt);
final person = 'person-${accounts.length + 1}';
addAccount(person, subject: subject, handle: handle, keys: setup);
return _grantFor(person);
}
@override
Future<protocol.KeyProofChallenge> beginMend(
protocol.UuidValue attempt,
String handle,
) async {
await _call('beginMend');
if (!_waiting.containsKey(attempt) || _person != null) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
final challenge = _newId();
final owner = accounts.entries
.where((entry) => entry.value.handle == handle)
.firstOrNull;
// A handle nobody has gets a decoy alike.
_challenges[challenge] = owner?.key ?? '';
final keys = owner?.value.keys;
return protocol.KeyProofChallenge(
challenge: challenge,
nonce: ByteData(32),
encryptionPublicKey: keys?.encryptionPublicKey ?? ByteData(32),
signingPublicKey: keys?.signingPublicKey ?? ByteData(32),
recoveryKeys: keys?.recoveryKeys ?? FakeAccountCrypto.wrap('', _decoy),
);
}
@override
Future<SimpleLoginAccount> mend(
protocol.UuidValue attempt,
protocol.KeysProof proof,
) async {
await _call('mend');
final subject = _waiting[attempt];
if (subject == null || _person != null) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
final owner = _challenges.remove(proof.challenge);
final account = accounts[owner];
if (owner == null ||
account == null ||
!FakeAccountCrypto.proves(
proof.signature,
account.keys?.recoveryProofKey,
)) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
_waiting.remove(attempt);
linked
..removeWhere((_, person) => person == owner)
..[subject] = owner;
_signedIn[attempt] = owner;
return SimpleLoginAccount(
attempt: attempt,
grant: _grantFor(owner),
hasKeys: account.keys != null,
handle: account.handle,
);
}
@override
Future<protocol.KeysChallenge> beginUnlock({SessionGrant? as}) async {
await _call('beginUnlock');
final person = _owner(as);
final keys = accounts[person]?.keys;
if (keys == null) {
throw const PasswordFailed(PasswordProblem.noPassword);
}
final challenge = _newId();
_challenges[challenge] = person;
return protocol.KeysChallenge(
challenge: challenge,
nonce: ByteData(32),
salt: keys.salt,
costs: keys.costs,
);
}
@override
Future<protocol.DeviceKeys> unlock(
protocol.KeysProof proof, {
SessionGrant? as,
}) async {
await _call('unlock');
final person = _owner(as);
if (_challenges.remove(proof.challenge) != person) {
throw const PasswordFailed(PasswordProblem.challengeExpired);
}
final keys = accounts[person]!.keys!;
if (!FakeAccountCrypto.provesPassword(proof, keys.passwordProofKey)) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
return protocol.DeviceKeys(
encryptionPublicKey: keys.encryptionPublicKey,
signingPublicKey: keys.signingPublicKey,
passwordKeys: keys.passwordKeys,
);
}
@override
Future<void> setKeys(
protocol.UuidValue attempt,
protocol.KeysSetup setup, {
String? handle,
SessionGrant? as,
}) async {
await _call('setKeys');
final person = _owner(as);
if (_signedIn[attempt] != person) {
throw const SimpleLoginFailed(SimpleLoginProblem.startAgain);
}
final account = accounts[person]!;
if (account.keys != null) {
throw const PasswordFailed(PasswordProblem.passwordExists);
}
if ((account.handle == null) != (handle != null)) {
throw const SimpleLoginFailed(SimpleLoginProblem.handleInvalid);
}
if (handle != null) {
if (_handleProblem(handle) case final problem?) {
throw SimpleLoginFailed(problem);
}
account.handle = handle;
}
_signedIn.remove(attempt);
account.keys = setup;
}
}