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 `, [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 = {}; /// Whose each SimpleLogin subject is, by subject. final linked = {}; /// The calls made, by name, in order. final calls = []; /// The ways back sign-ins began with, in order. final begunBack = []; /// 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 = {}; final _waiting = {}; final _signedIn = {}; final _challenges = {}; 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 _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 offered() async { await _call('offered'); return isOffered; } @override Future 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 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 checkHandle( protocol.UuidValue attempt, String handle, ) async { await _call('checkHandle'); if (!_waiting.containsKey(attempt)) { throw const SimpleLoginFailed(SimpleLoginProblem.startAgain); } return _handleProblem(handle); } @override Future 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 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 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 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 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 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; } }