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>
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;
|
|
}
|
|
}
|