363 lines
11 KiB
Dart
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;
|
||
|
|
}
|
||
|
|
}
|