feat(useragent): initial connection impl
This commit is contained in:
56
useragent/lib/features/connection/arbiter_url.dart
Normal file
56
useragent/lib/features/connection/arbiter_url.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class ArbiterUrl {
|
||||
const ArbiterUrl({
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.caCert,
|
||||
this.bootstrapToken,
|
||||
});
|
||||
|
||||
final String host;
|
||||
final int port;
|
||||
final List<int> caCert;
|
||||
final String? bootstrapToken;
|
||||
|
||||
static const _scheme = 'arbiter';
|
||||
static const _certQueryKey = 'cert';
|
||||
static const _bootstrapTokenQueryKey = 'bootstrap_token';
|
||||
|
||||
static ArbiterUrl parse(String value) {
|
||||
final uri = Uri.tryParse(value);
|
||||
if (uri == null || uri.scheme != _scheme) {
|
||||
throw const FormatException("Invalid URL scheme, expected 'arbiter://'");
|
||||
}
|
||||
|
||||
if (uri.host.isEmpty) {
|
||||
throw const FormatException('Missing host in URL');
|
||||
}
|
||||
|
||||
if (!uri.hasPort) {
|
||||
throw const FormatException('Missing port in URL');
|
||||
}
|
||||
|
||||
final cert = uri.queryParameters[_certQueryKey];
|
||||
if (cert == null || cert.isEmpty) {
|
||||
throw const FormatException("Missing 'cert' query parameter in URL");
|
||||
}
|
||||
|
||||
final decodedCert = _decodeCert(cert);
|
||||
|
||||
return ArbiterUrl(
|
||||
host: uri.host,
|
||||
port: uri.port,
|
||||
caCert: decodedCert,
|
||||
bootstrapToken: uri.queryParameters[_bootstrapTokenQueryKey],
|
||||
);
|
||||
}
|
||||
|
||||
static List<int> _decodeCert(String cert) {
|
||||
try {
|
||||
return base64Url.decode(base64Url.normalize(cert));
|
||||
} on FormatException catch (error) {
|
||||
throw FormatException("Invalid base64 in 'cert' query parameter: ${error.message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,132 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:arbiter/features/connection/server_info_storage.dart';
|
||||
import 'package:arbiter/features/identity/pk_manager.dart';
|
||||
import 'package:arbiter/proto/arbiter.pbgrpc.dart';
|
||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||
import 'package:grpc/grpc.dart';
|
||||
import 'package:mtcore/markettakers.dart';
|
||||
|
||||
class Connection {
|
||||
final ClientChannel channel;
|
||||
final StreamController<UserAgentRequest> _tx;
|
||||
final StreamIterator<UserAgentResponse> _rx;
|
||||
|
||||
Connection({
|
||||
required this.channel,
|
||||
required StreamController<UserAgentRequest> tx,
|
||||
required ResponseStream<UserAgentResponse> rx,
|
||||
}) : _tx = tx,
|
||||
_rx = StreamIterator(rx);
|
||||
|
||||
Future<void> send(UserAgentRequest request) async {
|
||||
_tx.add(request);
|
||||
}
|
||||
|
||||
Future<UserAgentResponse> receive() async {
|
||||
await _rx.moveNext();
|
||||
return _rx.current;
|
||||
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _tx.close();
|
||||
await channel.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Connection> _connect(StoredServerInfo serverInfo) async {
|
||||
final channel = ClientChannel(
|
||||
serverInfo.address,
|
||||
port: serverInfo.port,
|
||||
options: ChannelOptions(
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
credentials: ChannelCredentials.secure(
|
||||
onBadCertificate: (cert, host) {
|
||||
return true;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final client = ArbiterServiceClient(channel);
|
||||
final tx = StreamController<UserAgentRequest>();
|
||||
|
||||
final rx = client.userAgent(tx.stream);
|
||||
|
||||
return Connection(channel: channel, tx: tx, rx: rx);
|
||||
}
|
||||
|
||||
List<int> formatChallenge(AuthChallenge challenge, List<int> pubkey) {
|
||||
final encodedPubkey = base64Encode(pubkey);
|
||||
final payload = "${challenge.nonce}:$encodedPubkey";
|
||||
return utf8.encode(payload);
|
||||
}
|
||||
|
||||
Future<Connection> connectAndAuthorize(
|
||||
StoredServerInfo serverInfo,
|
||||
KeyHandle key, {
|
||||
String? bootstrapToken,
|
||||
}) async {
|
||||
try {
|
||||
final connection = await _connect(serverInfo);
|
||||
talker.info(
|
||||
'Connected to server at ${serverInfo.address}:${serverInfo.port}',
|
||||
);
|
||||
final pubkey = await key.getPublicKey();
|
||||
|
||||
final req = AuthChallengeRequest(
|
||||
pubkey: pubkey,
|
||||
bootstrapToken: bootstrapToken,
|
||||
keyType: switch (key.alg) {
|
||||
KeyAlgorithm.rsa => KeyType.KEY_TYPE_RSA,
|
||||
KeyAlgorithm.ecdsa => KeyType.KEY_TYPE_ECDSA_SECP256K1,
|
||||
KeyAlgorithm.ed25519 => KeyType.KEY_TYPE_ED25519,
|
||||
},
|
||||
);
|
||||
await connection.send(UserAgentRequest(authChallengeRequest: req));
|
||||
talker.info(
|
||||
"Sent auth challenge request with pubkey ${base64Encode(pubkey)}",
|
||||
);
|
||||
|
||||
|
||||
class Connection {}
|
||||
final response = await connection.receive();
|
||||
|
||||
talker.info(
|
||||
'Received response from server, checking for auth challenge...',
|
||||
);
|
||||
|
||||
if (!response.hasAuthChallenge()) {
|
||||
throw Exception(
|
||||
'Expected AuthChallengeResponse, got ${response.whichPayload()}',
|
||||
);
|
||||
}
|
||||
|
||||
final challenge = formatChallenge(response.authChallenge, pubkey);
|
||||
talker.info(
|
||||
'Received auth challenge, signing with key ${base64Encode(pubkey)}',
|
||||
);
|
||||
|
||||
final signature = await key.sign(challenge);
|
||||
|
||||
final solutionReq = AuthChallengeSolution(signature: signature);
|
||||
await connection.send(UserAgentRequest(authChallengeSolution: solutionReq));
|
||||
|
||||
talker.info('Sent auth challenge solution, waiting for server response...');
|
||||
|
||||
final solutionResponse = await connection.receive();
|
||||
|
||||
if (!solutionResponse.hasAuthOk()) {
|
||||
throw Exception(
|
||||
'Expected AuthChallengeSolutionResponse, got ${solutionResponse.whichPayload()}',
|
||||
);
|
||||
}
|
||||
|
||||
talker.info('Authentication successful, connection established');
|
||||
|
||||
return connection;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to connect to server: $e');
|
||||
}
|
||||
}
|
||||
|
||||
61
useragent/lib/features/connection/server_info_storage.dart
Normal file
61
useragent/lib/features/connection/server_info_storage.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'server_info_storage.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class StoredServerInfo {
|
||||
const StoredServerInfo({
|
||||
required this.address,
|
||||
required this.port,
|
||||
required this.caCertFingerprint,
|
||||
});
|
||||
|
||||
final String address;
|
||||
final int port;
|
||||
final String caCertFingerprint;
|
||||
|
||||
factory StoredServerInfo.fromJson(Map<String, dynamic> json) => _$StoredServerInfoFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$StoredServerInfoToJson(this);
|
||||
|
||||
}
|
||||
|
||||
abstract class ServerInfoStorage {
|
||||
Future<StoredServerInfo?> load();
|
||||
Future<void> save(StoredServerInfo serverInfo);
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
class SecureServerInfoStorage implements ServerInfoStorage {
|
||||
static const _storageKey = 'server_info';
|
||||
|
||||
const SecureServerInfoStorage();
|
||||
|
||||
static const _storage = FlutterSecureStorage();
|
||||
|
||||
@override
|
||||
Future<StoredServerInfo?> load() async {
|
||||
final rawValue = await _storage.read(key: _storageKey);
|
||||
if (rawValue == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(rawValue) as Map<String, dynamic>;
|
||||
return StoredServerInfo.fromJson(decoded);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(StoredServerInfo serverInfo) {
|
||||
return _storage.write(
|
||||
key: _storageKey,
|
||||
value: jsonEncode(serverInfo.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() {
|
||||
return _storage.delete(key: _storageKey);
|
||||
}
|
||||
}
|
||||
21
useragent/lib/features/connection/server_info_storage.g.dart
Normal file
21
useragent/lib/features/connection/server_info_storage.g.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'server_info_storage.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
StoredServerInfo _$StoredServerInfoFromJson(Map<String, dynamic> json) =>
|
||||
StoredServerInfo(
|
||||
address: json['address'] as String,
|
||||
port: (json['port'] as num).toInt(),
|
||||
caCertFingerprint: json['caCertFingerprint'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$StoredServerInfoToJson(StoredServerInfo instance) =>
|
||||
<String, dynamic>{
|
||||
'address': instance.address,
|
||||
'port': instance.port,
|
||||
'caCertFingerprint': instance.caCertFingerprint,
|
||||
};
|
||||
Reference in New Issue
Block a user