From afe68d5d3b8fb978bf5073a48b85fcdc79c66451 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Fri, 12 Jun 2026 14:49:55 +0200 Subject: [PATCH 01/25] getting started with REST implementation --- lib/src/noosphere_roast_server_base.dart | 2 + lib/src/rest.dart | 405 +++++++++++++++++++ lib/src/server/synchronized_api_handler.dart | 194 +++++++++ test/rest_test.dart | 156 +++++++ 4 files changed, 757 insertions(+) create mode 100644 lib/src/rest.dart create mode 100644 lib/src/server/synchronized_api_handler.dart create mode 100644 test/rest_test.dart diff --git a/lib/src/noosphere_roast_server_base.dart b/lib/src/noosphere_roast_server_base.dart index 818c6f7..ed73c08 100644 --- a/lib/src/noosphere_roast_server_base.dart +++ b/lib/src/noosphere_roast_server_base.dart @@ -3,4 +3,6 @@ export "package:noosphere_roast_client/noosphere_roast_client.dart"; export "config/grpc.dart"; export "config/server.dart"; export "grpc.dart"; +export "rest.dart"; export "server/api_handler.dart"; +export "server/synchronized_api_handler.dart"; diff --git a/lib/src/rest.dart b/lib/src/rest.dart new file mode 100644 index 0000000..781e306 --- /dev/null +++ b/lib/src/rest.dart @@ -0,0 +1,405 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:coinlib/coinlib.dart' as cl; +import 'package:noosphere_roast_client/noosphere_roast_client.dart'; +import 'package:noosphere_roast_server/src/server/api_handler.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:shelf_router/shelf_router.dart'; + +Uint8List _bytes(List li) => Uint8List.fromList(li); +SessionID _sid(List li) => SessionID.fromBytes(_bytes(li)); +SignaturesRequestId _sigReqId(List li) => + SignaturesRequestId.fromBytes(_bytes(li)); + +String _encodeBytes(List bytes) => base64Encode(bytes); +String _encodeUrlBytes(List bytes) => base64UrlEncode(bytes).replaceAll( + RegExp(r'=+$'), + '', + ); + +Uint8List _decodeBytes(String value) { + final base64Value = value.replaceAll('-', '+').replaceAll('_', '/'); + final padding = (4 - base64Value.length % 4) % 4; + return base64Decode(base64Value.padRight(base64Value.length + padding, '=')); +} + +Map _corsHeaders(String allowOrigin) => { + 'access-control-allow-origin': allowOrigin, + 'access-control-allow-methods': 'GET, POST, OPTIONS', + 'access-control-allow-headers': 'content-type', + 'access-control-max-age': '86400', + }; + +Middleware restSseCors({ + String allowOrigin = '*', +}) => + (innerHandler) => (request) async { + final headers = _corsHeaders(allowOrigin); + if (request.method == 'OPTIONS') { + return Response.ok('', headers: headers); + } + + final response = await innerHandler(request); + return response.change(headers: {...response.headers, ...headers}); + }; + +class RestSseNoosphereService { + final ServerApiHandler api; + final String allowOrigin; + + RestSseNoosphereService({ + required this.api, + this.allowOrigin = '*', + }); + + Handler get handler { + final router = Router() + ..post('/login', _login) + ..post('/respond-to-challenge', _respondToChallenge) + ..post('/extend-session', _extendSession) + ..post('/dkg/new', _requestNewDkg) + ..post('/dkg/reject', _rejectDkg) + ..post('/dkg/commitment', _submitDkgCommitment) + ..post('/dkg/round2', _submitDkgRound2) + ..post('/dkg/acks', _sendDkgAcks) + ..post('/dkg/request-acks', _requestDkgAcks) + ..post('/signatures/request', _requestSignatures) + ..post('/signatures/reject', _rejectSignaturesRequest) + ..post('/signatures/replies', _submitSignatureReplies) + ..post('/secret-share', _shareSecretShare) + ..post('/key-constructed/ack', _ackKeyConstructed) + ..get('/sessions//events', _fetchEventStream); + + return const Pipeline() + .addMiddleware(restSseCors(allowOrigin: allowOrigin)) + .addHandler(router.call); + } + + Future serve({ + Object address = 'localhost', + int port = 8080, + }) => + shelf_io.serve(handler, address, port); + + Future _login(Request request) => _handleJson(() async { + final json = await _readJson(request); + final resp = await api.login( + groupFingerprint: _fieldBytes(json, 'groupFingerprint'), + participantId: + Identifier.fromBytes(_fieldBytes(json, 'participantId')), + protocolVersion: _optionalInt(json, 'protocolVersion') ?? + ServerApiHandler.currentProtocolVersion, + ); + return _bytesResponse(resp.toBytes()); + }); + + Future _respondToChallenge(Request request) => _handleJson( + () async { + final json = await _readJson(request); + final resp = await api.respondToChallenge( + Signed( + obj: AuthChallenge.fromBytes(_fieldBytes(json, 'challenge')), + signature: cl.SchnorrSignature(_fieldBytes(json, 'signature')), + ), + ); + return _bytesResponse(resp.toBytes()); + }, + ); + + Future _extendSession(Request request) => _handleJson(() async { + final json = await _readJson(request); + final resp = await api.extendSession(_sid(_fieldBytes(json, 'sid'))); + return _bytesResponse(resp.toBytes()); + }); + + Future _requestNewDkg(Request request) => _handleEmpty(() async { + final json = await _readJson(request); + await api.requestNewDkg( + sid: _sid(_fieldBytes(json, 'sid')), + signedDetails: Signed.fromBytes( + _fieldBytes(json, 'signedDetails'), + (reader) => NewDkgDetails.fromReader(reader), + ), + commitment: DkgPublicCommitment.fromBytes( + _fieldBytes(json, 'commitment'), + ), + ); + }); + + Future _rejectDkg(Request request) => _handleEmpty(() async { + final json = await _readJson(request); + await api.rejectDkg( + sid: _sid(_fieldBytes(json, 'sid')), + name: _fieldString(json, 'name'), + ); + }); + + Future _submitDkgCommitment(Request request) => + _handleEmpty(() async { + final json = await _readJson(request); + await api.submitDkgCommitment( + sid: _sid(_fieldBytes(json, 'sid')), + name: _fieldString(json, 'name'), + commitment: DkgPublicCommitment.fromBytes( + _fieldBytes(json, 'commitment'), + ), + ); + }); + + Future _submitDkgRound2(Request request) => _handleEmpty(() async { + final json = await _readJson(request); + await api.submitDkgRound2( + sid: _sid(_fieldBytes(json, 'sid')), + name: _fieldString(json, 'name'), + commitmentSetSignature: cl.SchnorrSignature( + _fieldBytes(json, 'commitmentSetSignature'), + ), + secrets: { + for (final secret in _fieldList(json, 'secrets')) + Identifier.fromBytes(_fieldBytes(secret, 'id')): + DkgEncryptedSecret( + ECCiphertext.fromBytes(_fieldBytes(secret, 'secret')), + ), + }, + ); + }); + + Future _sendDkgAcks(Request request) => _handleEmpty(() async { + final json = await _readJson(request); + await api.sendDkgAcks( + sid: _sid(_fieldBytes(json, 'sid')), + acks: _fieldStringList(json, 'acks') + .map((ack) => SignedDkgAck.fromBytes(_decodeBytes(ack))) + .toSet(), + ); + }); + + Future _requestDkgAcks(Request request) => _handleJson(() async { + final json = await _readJson(request); + final resp = await api.requestDkgAcks( + sid: _sid(_fieldBytes(json, 'sid')), + requests: _fieldStringList(json, 'requests') + .map((req) => DkgAckRequest.fromBytes(_decodeBytes(req))) + .toSet(), + ); + return _repeatedBytesResponse(resp.map((ack) => ack.toBytes())); + }); + + Future _requestSignatures(Request request) => + _handleEmpty(() async { + final json = await _readJson(request); + await api.requestSignatures( + sid: _sid(_fieldBytes(json, 'sid')), + keys: _fieldStringList(json, 'keys') + .map((key) => AggregateKeyInfo.fromBytes(_decodeBytes(key))) + .toSet(), + signedDetails: Signed.fromBytes( + _fieldBytes(json, 'signedDetails'), + (reader) => SignaturesRequestDetails.fromReader(reader), + ), + commitments: _fieldStringList(json, 'commitments') + .map( + (commitment) => SigningCommitment.fromBytes( + _decodeBytes(commitment), + ), + ) + .toList(), + ); + }); + + Future _rejectSignaturesRequest(Request request) => + _handleEmpty(() async { + final json = await _readJson(request); + await api.rejectSignaturesRequest( + sid: _sid(_fieldBytes(json, 'sid')), + reqId: _sigReqId(_fieldBytes(json, 'reqId')), + ); + }); + + Future _submitSignatureReplies(Request request) => + _handleJson(() async { + final json = await _readJson(request); + final resp = await api.submitSignatureReplies( + sid: _sid(_fieldBytes(json, 'sid')), + reqId: _sigReqId(_fieldBytes(json, 'reqId')), + replies: _fieldStringList(json, 'replies') + .map((reply) => SignatureReply.fromBytes(_decodeBytes(reply))) + .toList(), + ); + + return _jsonResponse({ + 'type': switch (resp) { + SignatureNewRoundsResponse() => 'new_round', + SignaturesCompleteResponse() => 'complete', + null => 'empty', + }, + 'data': resp == null ? null : _encodeBytes(resp.toBytes()), + }); + }); + + Future _shareSecretShare(Request request) => _handleJson(() async { + final json = await _readJson(request); + final resp = await api.shareSecretShare( + sid: _sid(_fieldBytes(json, 'sid')), + groupKey: cl.ECCompressedPublicKey(_fieldBytes(json, 'groupKey')), + encryptedSecrets: { + for (final secret in _fieldList(json, 'secrets')) + Identifier.fromBytes(_fieldBytes(secret, 'id')): + EncryptedKeyShare( + ECCiphertext.fromBytes(_fieldBytes(secret, 'share')), + ), + }, + ); + return _repeatedBytesResponse(resp.map((ev) => ev.toBytes())); + }); + + Future _ackKeyConstructed(Request request) => + _handleEmpty(() async { + final json = await _readJson(request); + await api.ackKeyConstructed( + sid: _sid(_fieldBytes(json, 'sid')), + constructedKey: Signed.fromBytes( + _fieldBytes(json, 'constructedKey'), + (reader) => KeyWasConstructed.fromReader(reader), + ), + ); + }); + + Future _fetchEventStream(Request request, String sid) async { + try { + final session = api.getSession(_sid(_decodeBytes(sid))); + return Response.ok( + session.eventController.stream.map(_sseEvent), + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + 'x-accel-buffering': 'no', + }, + ); + } on InvalidRequest catch (e) { + return _jsonResponse({'error': e.message}, status: 400); + } on FormatException catch (e) { + return _jsonResponse({'error': e.message}, status: 400); + } on Exception { + return _jsonResponse({'error': 'Internal server error'}, status: 500); + } + } +} + +Future _handleEmpty(Future Function() action) async { + try { + await action(); + return _jsonResponse({}); + } on InvalidRequest catch (e) { + return _jsonResponse({'error': e.message}, status: 400); + } on FormatException catch (e) { + return _jsonResponse({'error': e.message}, status: 400); + } on Exception { + return _jsonResponse({'error': 'Internal server error'}, status: 500); + } +} + +Future _handleJson(Future Function() action) async { + try { + return await action(); + } on InvalidRequest catch (e) { + return _jsonResponse({'error': e.message}, status: 400); + } on FormatException catch (e) { + return _jsonResponse({'error': e.message}, status: 400); + } on Exception { + return _jsonResponse({'error': 'Internal server error'}, status: 500); + } +} + +Future> _readJson(Request request) async { + final body = await request.readAsString(); + final decoded = jsonDecode(body); + if (decoded is! Map) throw const FormatException('Expected JSON object'); + return { + for (final entry in decoded.entries) + if (entry.key is String) entry.key as String: entry.value, + }; +} + +Response _jsonResponse(Object value, {int status = 200}) => Response( + status, + body: jsonEncode(value), + headers: {'content-type': 'application/json'}, + ); + +Response _bytesResponse(List bytes) => + _jsonResponse({'data': _encodeBytes(bytes)}); + +Response _repeatedBytesResponse(Iterable> bytes) => + _jsonResponse({'data': bytes.map(_encodeBytes).toList()}); + +String _fieldString(Map json, String name) { + final value = json[name]; + if (value is! String) { + throw FormatException('Expected "$name" to be a string'); + } + return value; +} + +int? _optionalInt(Map json, String name) { + final value = json[name]; + if (value == null) return null; + if (value is! int) throw FormatException('Expected "$name" to be an int'); + return value; +} + +Uint8List _fieldBytes(Map json, String name) => + _decodeBytes(_fieldString(json, name)); + +List> _fieldList(Map json, String name) { + final value = json[name]; + if (value is! List) throw FormatException('Expected "$name" to be a list'); + return value.map((entry) { + if (entry is! Map) { + throw FormatException('Expected "$name" entries to be objects'); + } + return { + for (final mapEntry in entry.entries) + if (mapEntry.key is String) mapEntry.key as String: mapEntry.value, + }; + }).toList(); +} + +List _fieldStringList(Map json, String name) { + final value = json[name]; + if (value is! List) throw FormatException('Expected "$name" to be a list'); + return value.map((entry) { + if (entry is! String) { + throw FormatException('Expected "$name" entries to be strings'); + } + return entry; + }).toList(); +} + +List _sseEvent(Event event) => utf8.encode( + 'event: ${_eventType(event)}\n' + 'data: ${_encodeBytes(event.toBytes())}\n\n', + ); + +String _eventType(Event event) => switch (event) { + ParticipantStatusEvent() => 'participant_status', + NewDkgEvent() => 'new_dkg', + DkgCommitmentEvent() => 'dkg_commitment', + DkgRejectEvent() => 'dkg_reject', + DkgRound2ShareEvent() => 'dkg_round2_share', + DkgAckEvent() => 'dkg_ack', + DkgAckRequestEvent() => 'dkg_ack_request', + SignaturesRequestEvent() => 'signatures_request', + SignatureNewRoundsEvent() => 'signature_new_rounds', + SignaturesCompleteEvent() => 'signatures_complete', + SignaturesFailureEvent() => 'signatures_failure', + SecretShareEvent() => 'secret_share', + ConstructedKeyEvent() => 'constructed_key', + KeepaliveEvent() => 'keepalive', + }; + +String restSseSessionPath(SessionID sid) => + '/sessions/${_encodeUrlBytes(sid.n)}/events'; diff --git a/lib/src/server/synchronized_api_handler.dart b/lib/src/server/synchronized_api_handler.dart new file mode 100644 index 0000000..fdce306 --- /dev/null +++ b/lib/src/server/synchronized_api_handler.dart @@ -0,0 +1,194 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:coinlib/coinlib.dart' as cl; +import 'package:noosphere_roast_client/noosphere_roast_client.dart'; +import 'package:noosphere_roast_server/src/server/api_handler.dart'; + +class _ApiCallQueue { + Future _tail = Future.value(); + + Future run(Future Function() action) { + final previous = _tail; + final completer = Completer(); + + _tail = previous.catchError((_) {}).then((_) async { + try { + completer.complete(await action()); + } catch (e, st) { + completer.completeError(e, st); + } + }); + + return completer.future; + } +} + +/// A [ServerApiHandler] that serializes state-mutating API calls. +/// +/// Use one shared instance of this class when exposing the same coordinator +/// through multiple transports, such as gRPC for desktop clients and REST/SSE +/// for web clients. +class SynchronizedServerApiHandler extends ServerApiHandler { + final _queue = _ApiCallQueue(); + + SynchronizedServerApiHandler({ + required super.config, + super.state, + }); + + @override + Future login({ + required Uint8List groupFingerprint, + required Identifier participantId, + int protocolVersion = ServerApiHandler.currentProtocolVersion, + }) => + _queue.run( + () => super.login( + groupFingerprint: groupFingerprint, + participantId: participantId, + protocolVersion: protocolVersion, + ), + ); + + @override + Future respondToChallenge( + Signed signedChallenge, + ) => + _queue.run(() => super.respondToChallenge(signedChallenge)); + + @override + Future extendSession(SessionID sid) => + _queue.run(() => super.extendSession(sid)); + + @override + Future requestNewDkg({ + required SessionID sid, + required Signed signedDetails, + required DkgPublicCommitment commitment, + }) => + _queue.run( + () => super.requestNewDkg( + sid: sid, + signedDetails: signedDetails, + commitment: commitment, + ), + ); + + @override + Future rejectDkg({ + required SessionID sid, + required String name, + }) => + _queue.run(() => super.rejectDkg(sid: sid, name: name)); + + @override + Future submitDkgCommitment({ + required SessionID sid, + required String name, + required DkgPublicCommitment commitment, + }) => + _queue.run( + () => super.submitDkgCommitment( + sid: sid, + name: name, + commitment: commitment, + ), + ); + + @override + Future submitDkgRound2({ + required SessionID sid, + required String name, + required cl.SchnorrSignature commitmentSetSignature, + required Map secrets, + }) => + _queue.run( + () => super.submitDkgRound2( + sid: sid, + name: name, + commitmentSetSignature: commitmentSetSignature, + secrets: secrets, + ), + ); + + @override + Future sendDkgAcks({ + required SessionID sid, + required Set acks, + }) => + _queue.run(() => super.sendDkgAcks(sid: sid, acks: acks)); + + @override + Future> requestDkgAcks({ + required SessionID sid, + required Set requests, + }) => + _queue.run( + () => super.requestDkgAcks(sid: sid, requests: requests), + ); + + @override + Future requestSignatures({ + required SessionID sid, + required Set keys, + required Signed signedDetails, + required List commitments, + }) => + _queue.run( + () => super.requestSignatures( + sid: sid, + keys: keys, + signedDetails: signedDetails, + commitments: commitments, + ), + ); + + @override + Future rejectSignaturesRequest({ + required SessionID sid, + required SignaturesRequestId reqId, + }) => + _queue.run( + () => super.rejectSignaturesRequest(sid: sid, reqId: reqId), + ); + + @override + Future submitSignatureReplies({ + required SessionID sid, + required SignaturesRequestId reqId, + required List replies, + }) => + _queue.run( + () => super.submitSignatureReplies( + sid: sid, + reqId: reqId, + replies: replies, + ), + ); + + @override + Future> shareSecretShare({ + required SessionID sid, + required cl.ECCompressedPublicKey groupKey, + required Map encryptedSecrets, + }) => + _queue.run( + () => super.shareSecretShare( + sid: sid, + groupKey: groupKey, + encryptedSecrets: encryptedSecrets, + ), + ); + + @override + Future ackKeyConstructed({ + required SessionID sid, + required Signed constructedKey, + }) => + _queue.run( + () => super.ackKeyConstructed( + sid: sid, + constructedKey: constructedKey, + ), + ); +} diff --git a/test/rest_test.dart b/test/rest_test.dart new file mode 100644 index 0000000..fa49753 --- /dev/null +++ b/test/rest_test.dart @@ -0,0 +1,156 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:noosphere_roast_server/noosphere_roast_server.dart'; +import 'package:noosphere_roast_server/src/server/state/client_session.dart'; +import 'package:shelf/shelf.dart'; +import 'package:test/test.dart'; + +String _b64(List bytes) => base64Encode(bytes); +Uint8List _dataBytes(String body) { + final json = jsonDecode(body) as Map; + return base64Decode(json['data'] as String); +} + +Request _jsonPost(String path, Map body) => Request( + 'POST', + Uri.parse('http://localhost$path'), + body: jsonEncode(body), + headers: {'content-type': 'application/json'}, + ); + +Request _get(String path) => Request('GET', Uri.parse('http://localhost$path')); + +Request _options(String path) => Request( + 'OPTIONS', + Uri.parse('http://localhost$path'), + ); + +Future _post( + Handler handler, + String path, + Map body, +) async => + await handler(_jsonPost(path, body)); + +SessionID _sid([int lastByte = 1]) => + SessionID.fromBytes(Uint8List(16)..last = lastByte); + +class _FakeSession implements ClientSession { + @override + final StreamController eventController; + + _FakeSession({void Function()? onCancel}) + : eventController = StreamController(onCancel: onCancel); + + void send(Event event) => eventController.add(event); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _RestTestApi implements ServerApiHandler { + final expiry = Expiry(Duration(minutes: 5)); + final sessions = {}; + + @override + Future extendSession(SessionID sid) async { + if (!sessions.containsKey(sid)) throw InvalidRequest.noSession(); + return expiry; + } + + @override + ClientSession getSession(SessionID id) { + final session = sessions[id]; + if (session == null) throw InvalidRequest.noSession(); + return session; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + group('RestSseNoosphereService', () { + late _RestTestApi api; + late Handler handler; + + setUp(() { + api = _RestTestApi(); + handler = RestSseNoosphereService( + api: api, + allowOrigin: 'https://app.example', + ).handler; + }); + + test('handles CORS preflight requests', () async { + final response = await handler(_options('/extend-session')); + + expect(response.statusCode, 200); + expect( + response.headers['access-control-allow-origin'], + 'https://app.example', + ); + expect( + response.headers['access-control-allow-methods'], + contains('POST'), + ); + }); + + test('maps invalid requests to JSON errors with CORS headers', () async { + final response = await _post(handler, '/extend-session', {}); + + expect(response.statusCode, 400); + expect(response.headers['content-type'], 'application/json'); + expect( + response.headers['access-control-allow-origin'], + 'https://app.example', + ); + + final body = jsonDecode(await response.readAsString()); + expect(body, {'error': 'Expected "sid" to be a string'}); + }); + + test('extends a session through REST', () async { + final sid = _sid(); + api.sessions[sid] = _FakeSession(); + + final response = await _post(handler, '/extend-session', { + 'sid': _b64(sid.toBytes()), + }); + + expect(response.statusCode, 200); + expect( + Expiry.fromBytes(_dataBytes(await response.readAsString())).ttl, + api.expiry.ttl, + ); + }); + + test('streams SSE events and cancels the session stream', () async { + var canceled = false; + final sid = _sid(); + final session = _FakeSession(onCancel: () => canceled = true); + api.sessions[sid] = session; + + final response = await handler(_get(restSseSessionPath(sid))); + expect(response.statusCode, 200); + expect(response.headers['content-type'], 'text/event-stream'); + expect(response.headers['cache-control'], 'no-cache'); + expect(response.headers['x-accel-buffering'], 'no'); + + session.send(KeepaliveEvent()); + + final chunk = await response.read().first.timeout(Duration(seconds: 2)); + expect(utf8.decode(chunk), 'event: keepalive\ndata: \n\n'); + expect(canceled, true); + }); + + test('returns a clean error for an unknown SSE session', () async { + final response = await handler(_get(restSseSessionPath(_sid(2)))); + + expect(response.statusCode, 400); + final body = jsonDecode(await response.readAsString()); + expect(body, {'error': InvalidRequest.noSession().message}); + }); + }); +} From 6993d311bf72a5193d5f682eed325342a49fd558 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Fri, 12 Jun 2026 14:51:42 +0200 Subject: [PATCH 02/25] CLI args for REST --- bin/grpc_server.dart | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/bin/grpc_server.dart b/bin/grpc_server.dart index 6e19b67..389781f 100644 --- a/bin/grpc_server.dart +++ b/bin/grpc_server.dart @@ -5,7 +5,6 @@ import 'package:coinlib/coinlib.dart'; import 'package:noosphere_roast_server/noosphere_roast_server.dart'; void main(List args) async { - final argParser = ArgParser(); argParser.addOption( "config", @@ -13,8 +12,19 @@ void main(List args) async { help: "The path to the GrpcConfig YAML file", mandatory: true, ); + argParser.addOption( + "rest-port", + help: "Optional REST/SSE port for browser clients", + ); + argParser.addOption( + "rest-allow-origin", + help: "CORS Access-Control-Allow-Origin value for REST/SSE clients", + defaultsTo: "*", + ); final argResults = argParser.parse(args); final configFile = argResults.option("config")!; + final restPortString = argResults.option("rest-port"); + final restPort = restPortString == null ? null : int.parse(restPortString); final configString = File(configFile).readAsStringSync(); await loadFrosty(); @@ -23,11 +33,21 @@ void main(List args) async { print("Loaded config from $configFile"); print("Group fingerprint is ${bytesToHex(config.server.group.fingerprint)}"); - final apiHandler = ServerApiHandler(config: config.server); + final apiHandler = SynchronizedServerApiHandler(config: config.server); final service = FrostNoosphereService(api: apiHandler); final grpcServer = service.createServer(); await grpcServer.serve(port: config.port); - print("Server listening on port ${config.port}"); + print("gRPC server listening on port ${config.port}"); + + HttpServer? restServer; + if (restPort != null) { + final restService = RestSseNoosphereService( + api: apiHandler, + allowOrigin: argResults.option("rest-allow-origin")!, + ); + restServer = await restService.serve(port: restPort); + print("REST/SSE server listening on port ${restServer.port}"); + } // Wait for SIGINT or SIGTERM to terminate server @@ -47,8 +67,8 @@ void main(List args) async { print("Caught ${signal.name}. Shutting down server."); await apiHandler.shutdown(); + await restServer?.close(force: true); await grpcServer.shutdown(); exit(0); - } From 0044c03de7193c3996aa015ef52669e950274127 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Fri, 12 Jun 2026 14:53:41 +0200 Subject: [PATCH 03/25] shelf and shelf_router deps --- pubspec.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pubspec.yaml b/pubspec.yaml index 5b38c39..818c215 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,8 @@ dependencies: collection: ^1.17.1 grpc: ^4.0.1 args: ^2.6.0 + shelf: ^1.4.2 + shelf_router: ^1.1.4 dev_dependencies: lints: ^6.0.0 From 0147dc3d938676d17c10dd831b50786565d131da Mon Sep 17 00:00:00 2001 From: peerchemist Date: Mon, 15 Jun 2026 17:01:54 +0200 Subject: [PATCH 04/25] Implement package logging --- bin/grpc_server.dart | 18 +- lib/src/grpc.dart | 428 +++++++++++++---------- lib/src/logging.dart | 44 +++ lib/src/noosphere_roast_server_base.dart | 1 + lib/src/rest.dart | 89 ++++- lib/src/server/api_handler.dart | 368 ++++++++++--------- lib/src/server/state/state.dart | 35 +- pubspec.yaml | 1 + test/rest_test.dart | 6 +- 9 files changed, 587 insertions(+), 403 deletions(-) create mode 100644 lib/src/logging.dart diff --git a/bin/grpc_server.dart b/bin/grpc_server.dart index 389781f..48cdb8e 100644 --- a/bin/grpc_server.dart +++ b/bin/grpc_server.dart @@ -30,14 +30,16 @@ void main(List args) async { await loadFrosty(); final config = GrpcConfig.fromYaml(configString); - print("Loaded config from $configFile"); - print("Group fingerprint is ${bytesToHex(config.server.group.fingerprint)}"); + noosphereRoastServerLogger.i("Loaded config from $configFile"); + noosphereRoastServerLogger.i( + "Group fingerprint is ${bytesToHex(config.server.group.fingerprint)}", + ); final apiHandler = SynchronizedServerApiHandler(config: config.server); final service = FrostNoosphereService(api: apiHandler); final grpcServer = service.createServer(); await grpcServer.serve(port: config.port); - print("gRPC server listening on port ${config.port}"); + noosphereRoastServerLogger.i("gRPC server listening on port ${config.port}"); HttpServer? restServer; if (restPort != null) { @@ -46,7 +48,9 @@ void main(List args) async { allowOrigin: argResults.option("rest-allow-origin")!, ); restServer = await restService.serve(port: restPort); - print("REST/SSE server listening on port ${restServer.port}"); + noosphereRoastServerLogger.i( + "REST/SSE server listening on port ${restServer.port}", + ); } // Wait for SIGINT or SIGTERM to terminate server @@ -56,7 +60,7 @@ void main(List args) async { for (final signal in [ProcessSignal.sigint, ProcessSignal.sigterm]) { signal.watch().listen((sig) { if (termCompleter.isCompleted) { - print("Exiting immediately"); + noosphereRoastServerLogger.w("Exiting immediately"); exit(0); } termCompleter.complete(sig); @@ -64,7 +68,9 @@ void main(List args) async { } final signal = await termCompleter.future; - print("Caught ${signal.name}. Shutting down server."); + noosphereRoastServerLogger.i( + "Caught ${signal.name}. Shutting down server.", + ); await apiHandler.shutdown(); await restServer?.close(force: true); diff --git a/lib/src/grpc.dart b/lib/src/grpc.dart index 99eb4c6..1f76c39 100644 --- a/lib/src/grpc.dart +++ b/lib/src/grpc.dart @@ -4,85 +4,105 @@ import 'package:coinlib/coinlib.dart' as cl; import 'package:grpc/grpc.dart' as grpc; import 'package:noosphere_roast_client/pbgrpc.dart' as pb; import 'package:noosphere_roast_client/noosphere_roast_client.dart'; +import 'package:noosphere_roast_server/src/logging.dart'; import 'package:noosphere_roast_server/src/server/api_handler.dart'; import 'package:noosphere_roast_server/src/server/state/client_session.dart'; Uint8List _bytes(List li) => Uint8List.fromList(li); SessionID _sid(List li) => SessionID.fromBytes(_bytes(li)); -SignaturesRequestId _sigReqId(List li) - => SignaturesRequestId.fromBytes(_bytes(li)); +SignaturesRequestId _sigReqId(List li) => + SignaturesRequestId.fromBytes(_bytes(li)); pb.Bytes _returnWritable(cl.Writable writable) => pb.Bytes( - data: writable.toBytes(), -); + data: writable.toBytes(), + ); class FrostNoosphereService extends pb.NoosphereServiceBase { - final ServerApiHandler api; - FrostNoosphereService({ required this.api }); + FrostNoosphereService({required this.api}); grpc.Server createServer() => grpc.Server.create(services: [this]); - grpc.GrpcError _wrapException(Exception e) - => grpc.GrpcError.unknown(e.toString()); + grpc.GrpcError _wrapException( + String method, + Exception e, [ + StackTrace? stackTrace, + ]) { + if (e is InvalidRequest) { + noosphereRoastServerLogger.w("gRPC $method rejected: ${e.message}"); + } else { + noosphereRoastServerLogger.e( + "gRPC $method failed", + error: e, + stackTrace: stackTrace, + ); + } + return grpc.GrpcError.unknown(e.toString()); + } - Future _handleExceptions(Future Function() f) async { + Future _handleExceptions( + String method, + Future Function() f, + ) async { try { return await f(); - } on Exception catch(e) { - throw _wrapException(e); + } on Exception catch (e, stackTrace) { + throw _wrapException(method, e, stackTrace); } } - Future _handleEmpty(Future Function() f) async { - await _handleExceptions(f); + Future _handleEmpty( + String method, + Future Function() f, + ) async { + await _handleExceptions(method, f); return pb.Empty(); } @override Future login( - grpc.ServiceCall call, pb.LoginRequest request, - ) => _handleExceptions(() async { - - final resp = await api.login( - groupFingerprint: _bytes(request.groupFingerprint), - participantId: Identifier.fromBytes( - _bytes(request.participantId), - ), - protocolVersion: request.protocolVersion, - ); - - return _returnWritable(resp); - - }); + grpc.ServiceCall call, + pb.LoginRequest request, + ) => + _handleExceptions("login", () async { + final resp = await api.login( + groupFingerprint: _bytes(request.groupFingerprint), + participantId: Identifier.fromBytes( + _bytes(request.participantId), + ), + protocolVersion: request.protocolVersion, + ); + + return _returnWritable(resp); + }); @override Future respondToChallenge( - grpc.ServiceCall call, pb.SignedAuthChallenge request, - ) => _handleExceptions(() async { - - final resp = await api.respondToChallenge( - Signed( - obj: AuthChallenge.fromBytes(_bytes(request.challenge)), - signature: cl.SchnorrSignature(_bytes(request.signature)), - ), - ); - - return _returnWritable(resp); - - }); + grpc.ServiceCall call, + pb.SignedAuthChallenge request, + ) => + _handleExceptions("respondToChallenge", () async { + final resp = await api.respondToChallenge( + Signed( + obj: AuthChallenge.fromBytes(_bytes(request.challenge)), + signature: cl.SchnorrSignature(_bytes(request.signature)), + ), + ); + + return _returnWritable(resp); + }); @override Stream fetchEventStream( - grpc.ServiceCall call, pb.Bytes request, + grpc.ServiceCall call, + pb.Bytes request, ) { - final sessionId = _sid(request.data); late final ClientSession session; try { session = api.getSession(sessionId); - } on Exception catch(e) { - throw _wrapException(e); + } on Exception catch (e, stackTrace) { + throw _wrapException("fetchEventStream", e, stackTrace); } // sendTrailers is not always called automatically when the stream ends @@ -93,8 +113,8 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { ); // When upstream stream is done, cancel this one controller.addStream(session.eventController.stream).then( - (_) => controller.close(), - ); + (_) => controller.close(), + ); // Pass across all events return controller.stream.map( @@ -118,186 +138,220 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { }, ), ); - } @override Future extendSession( - grpc.ServiceCall call, pb.Bytes request, - ) => _handleExceptions(() async { - final resp = await api.extendSession(_sid(request.data)); - return _returnWritable(resp); - }); + grpc.ServiceCall call, + pb.Bytes request, + ) => + _handleExceptions("extendSession", () async { + final resp = await api.extendSession(_sid(request.data)); + return _returnWritable(resp); + }); @override Future requestNewDkg( - grpc.ServiceCall call, pb.DkgRequest request, - ) => _handleEmpty( - () => api.requestNewDkg( - sid: _sid(request.sid), - signedDetails: Signed.fromBytes( - _bytes(request.signedDetails), - (reader) => NewDkgDetails.fromReader(reader), - ), - commitment: DkgPublicCommitment.fromBytes( - _bytes(request.commitment), - ), - ), - ); + grpc.ServiceCall call, + pb.DkgRequest request, + ) => + _handleEmpty( + "requestNewDkg", + () => api.requestNewDkg( + sid: _sid(request.sid), + signedDetails: Signed.fromBytes( + _bytes(request.signedDetails), + (reader) => NewDkgDetails.fromReader(reader), + ), + commitment: DkgPublicCommitment.fromBytes( + _bytes(request.commitment), + ), + ), + ); @override Future rejectDkg( - grpc.ServiceCall call, pb.DkgToReject request, - ) => _handleEmpty( - () => api.rejectDkg(sid: _sid(request.sid), name: request.name), - ); + grpc.ServiceCall call, + pb.DkgToReject request, + ) => + _handleEmpty( + "rejectDkg", + () => api.rejectDkg(sid: _sid(request.sid), name: request.name), + ); @override Future submitDkgCommitment( - grpc.ServiceCall call, pb.DkgCommitment request, - ) => _handleEmpty( - () => api.submitDkgCommitment( - sid: _sid(request.sid), - name: request.name, - commitment: DkgPublicCommitment.fromBytes( - _bytes(request.commitment), - ), - ), - ); + grpc.ServiceCall call, + pb.DkgCommitment request, + ) => + _handleEmpty( + "submitDkgCommitment", + () => api.submitDkgCommitment( + sid: _sid(request.sid), + name: request.name, + commitment: DkgPublicCommitment.fromBytes( + _bytes(request.commitment), + ), + ), + ); @override Future submitDkgRound2( - grpc.ServiceCall call, pb.DkgRound2 request, - ) => _handleEmpty( - () => api.submitDkgRound2( - sid: _sid(request.sid), - name: request.name, - commitmentSetSignature: cl.SchnorrSignature( - _bytes(request.commitmentSetSignature), - ), - secrets: { - for (final secret in request.secrets) - Identifier.fromBytes(_bytes(secret.id)) - : DkgEncryptedSecret(ECCiphertext.fromBytes(_bytes(secret.secret))), - }, - ), - ); + grpc.ServiceCall call, + pb.DkgRound2 request, + ) => + _handleEmpty( + "submitDkgRound2", + () => api.submitDkgRound2( + sid: _sid(request.sid), + name: request.name, + commitmentSetSignature: cl.SchnorrSignature( + _bytes(request.commitmentSetSignature), + ), + secrets: { + for (final secret in request.secrets) + Identifier.fromBytes(_bytes(secret.id)): DkgEncryptedSecret( + ECCiphertext.fromBytes(_bytes(secret.secret)), + ), + }, + ), + ); @override Future sendDkgAcks( - grpc.ServiceCall call, pb.DkgAcks request, - ) => _handleEmpty( - () => api.sendDkgAcks( - sid: _sid(request.sid), - acks: request.acks.map( - (ack) => SignedDkgAck.fromBytes(_bytes(ack)), - ).toSet(), - ), - ); + grpc.ServiceCall call, + pb.DkgAcks request, + ) => + _handleEmpty( + "sendDkgAcks", + () => api.sendDkgAcks( + sid: _sid(request.sid), + acks: request.acks + .map( + (ack) => SignedDkgAck.fromBytes(_bytes(ack)), + ) + .toSet(), + ), + ); @override Future requestDkgAcks( - grpc.ServiceCall call, pb.DkgAckRequest request, - ) => _handleExceptions(() async { - - final resp = await api.requestDkgAcks( - sid: _sid(request.sid), - requests: request.requests.map( - (request) => DkgAckRequest.fromBytes(_bytes(request)), - ).toSet(), - ); - - return pb.RepeatedBytes(data: resp.map((ack) => ack.toBytes())); - - }); + grpc.ServiceCall call, + pb.DkgAckRequest request, + ) => + _handleExceptions("requestDkgAcks", () async { + final resp = await api.requestDkgAcks( + sid: _sid(request.sid), + requests: request.requests + .map( + (request) => DkgAckRequest.fromBytes(_bytes(request)), + ) + .toSet(), + ); + + return pb.RepeatedBytes(data: resp.map((ack) => ack.toBytes())); + }); @override Future requestSignatures( - grpc.ServiceCall call, pb.SignaturesRequest request, - ) => _handleEmpty( - () => api.requestSignatures( - sid: _sid(request.sid), - keys: request.keys.map( - (key) => AggregateKeyInfo.fromBytes(_bytes(key)), - ).toSet(), - signedDetails: Signed.fromBytes( - _bytes(request.signedDetails), - (reader) => SignaturesRequestDetails.fromReader(reader), - ), - commitments: request.commitments.map( - (commitment) => SigningCommitment.fromBytes(_bytes(commitment)), - ).toList(), - ), - ); + grpc.ServiceCall call, + pb.SignaturesRequest request, + ) => + _handleEmpty( + "requestSignatures", + () => api.requestSignatures( + sid: _sid(request.sid), + keys: request.keys + .map( + (key) => AggregateKeyInfo.fromBytes(_bytes(key)), + ) + .toSet(), + signedDetails: Signed.fromBytes( + _bytes(request.signedDetails), + (reader) => SignaturesRequestDetails.fromReader(reader), + ), + commitments: request.commitments + .map( + (commitment) => SigningCommitment.fromBytes(_bytes(commitment)), + ) + .toList(), + ), + ); @override Future rejectSignaturesRequest( - grpc.ServiceCall call, pb.SignaturesRejection request, - ) => _handleEmpty( - () => api.rejectSignaturesRequest( - sid: _sid(request.sid), - reqId: _sigReqId(request.reqId), - ), - ); + grpc.ServiceCall call, + pb.SignaturesRejection request, + ) => + _handleEmpty( + "rejectSignaturesRequest", + () => api.rejectSignaturesRequest( + sid: _sid(request.sid), + reqId: _sigReqId(request.reqId), + ), + ); @override Future submitSignatureReplies( - grpc.ServiceCall call, pb.SignaturesReplies request, - ) => _handleExceptions(() async { - - final resp = await api.submitSignatureReplies( - sid: _sid(request.sid), - reqId: _sigReqId(request.reqId), - replies: request.replies.map( - (reply) => SignatureReply.fromBytes(_bytes(reply)), - ).toList(), - ); - - return pb.SignaturesResponse( - type: switch (resp) { - SignatureNewRoundsResponse() - => pb.SignaturesResponseType.SIGNATURES_RESPONSE_NEW_ROUND, - SignaturesCompleteResponse() - => pb.SignaturesResponseType.SIGNATURES_RESPONSE_COMPLETE, - null => pb.SignaturesResponseType.SIGNATURES_RESPONSE_EMPTY, - }, - data: resp?.toBytes(), - ); - - }); + grpc.ServiceCall call, + pb.SignaturesReplies request, + ) => + _handleExceptions("submitSignatureReplies", () async { + final resp = await api.submitSignatureReplies( + sid: _sid(request.sid), + reqId: _sigReqId(request.reqId), + replies: request.replies + .map( + (reply) => SignatureReply.fromBytes(_bytes(reply)), + ) + .toList(), + ); + + return pb.SignaturesResponse( + type: switch (resp) { + SignatureNewRoundsResponse() => + pb.SignaturesResponseType.SIGNATURES_RESPONSE_NEW_ROUND, + SignaturesCompleteResponse() => + pb.SignaturesResponseType.SIGNATURES_RESPONSE_COMPLETE, + null => pb.SignaturesResponseType.SIGNATURES_RESPONSE_EMPTY, + }, + data: resp?.toBytes(), + ); + }); @override Future shareSecretShare( grpc.ServiceCall call, pb.SecretShare request, - ) => _handleExceptions(() async { - - final resp = await api.shareSecretShare( - sid: _sid(request.sid), - groupKey: cl.ECCompressedPublicKey(_bytes(request.groupKey)), - encryptedSecrets: { - for (final secret in request.secrets) - Identifier.fromBytes(_bytes(secret.id)) - : EncryptedKeyShare(ECCiphertext.fromBytes(_bytes(secret.share))), - }, - ); - - return pb.RepeatedBytes(data: resp.map((ev) => ev.toBytes())); - - }); + ) => + _handleExceptions("shareSecretShare", () async { + final resp = await api.shareSecretShare( + sid: _sid(request.sid), + groupKey: cl.ECCompressedPublicKey(_bytes(request.groupKey)), + encryptedSecrets: { + for (final secret in request.secrets) + Identifier.fromBytes(_bytes(secret.id)): EncryptedKeyShare( + ECCiphertext.fromBytes(_bytes(secret.share)), + ), + }, + ); + + return pb.RepeatedBytes(data: resp.map((ev) => ev.toBytes())); + }); @override Future ackKeyConstructed( grpc.ServiceCall call, pb.ConstructedKey request, - ) => _handleEmpty( - () => api.ackKeyConstructed( - sid: _sid(request.sid), - constructedKey: Signed.fromBytes( - _bytes(request.constructedKey), - (reader) => KeyWasConstructed.fromReader(reader), - ), - ), - ); - + ) => + _handleEmpty( + "ackKeyConstructed", + () => api.ackKeyConstructed( + sid: _sid(request.sid), + constructedKey: Signed.fromBytes( + _bytes(request.constructedKey), + (reader) => KeyWasConstructed.fromReader(reader), + ), + ), + ); } diff --git a/lib/src/logging.dart b/lib/src/logging.dart new file mode 100644 index 0000000..040a25c --- /dev/null +++ b/lib/src/logging.dart @@ -0,0 +1,44 @@ +export 'package:logger/logger.dart' + show Level, Logger, LogFilter, LogOutput, LogPrinter; + +import 'package:logger/logger.dart'; + +Logger _defaultNoosphereRoastServerLogger({ + Level level = Level.info, + LogFilter? filter, + LogPrinter? printer, + LogOutput? output, +}) => + Logger( + filter: filter ?? ProductionFilter(), + printer: printer ?? SimplePrinter(printTime: true, colors: false), + output: output, + level: level, + ); + +/// Logger used by this package. +/// +/// Replace it with [configureNoosphereRoastServerLogging] when embedding the +/// package in an application that already has logging configured. +Logger noosphereRoastServerLogger = _defaultNoosphereRoastServerLogger(); + +/// Configures the logger used by this package. +/// +/// Pass [logger] to take full control, or pass individual logger components to +/// keep the package defaults while changing the level, filter, printer, or +/// output. +void configureNoosphereRoastServerLogging({ + Logger? logger, + Level level = Level.info, + LogFilter? filter, + LogPrinter? printer, + LogOutput? output, +}) { + noosphereRoastServerLogger = logger ?? + _defaultNoosphereRoastServerLogger( + level: level, + filter: filter, + printer: printer, + output: output, + ); +} diff --git a/lib/src/noosphere_roast_server_base.dart b/lib/src/noosphere_roast_server_base.dart index ed73c08..ac54ba0 100644 --- a/lib/src/noosphere_roast_server_base.dart +++ b/lib/src/noosphere_roast_server_base.dart @@ -3,6 +3,7 @@ export "package:noosphere_roast_client/noosphere_roast_client.dart"; export "config/grpc.dart"; export "config/server.dart"; export "grpc.dart"; +export "logging.dart"; export "rest.dart"; export "server/api_handler.dart"; export "server/synchronized_api_handler.dart"; diff --git a/lib/src/rest.dart b/lib/src/rest.dart index 781e306..78af7c0 100644 --- a/lib/src/rest.dart +++ b/lib/src/rest.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:coinlib/coinlib.dart' as cl; import 'package:noosphere_roast_client/noosphere_roast_client.dart'; +import 'package:noosphere_roast_server/src/logging.dart'; import 'package:noosphere_roast_server/src/server/api_handler.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; @@ -84,7 +85,7 @@ class RestSseNoosphereService { }) => shelf_io.serve(handler, address, port); - Future _login(Request request) => _handleJson(() async { + Future _login(Request request) => _handleJson(request, () async { final json = await _readJson(request); final resp = await api.login( groupFingerprint: _fieldBytes(json, 'groupFingerprint'), @@ -97,6 +98,7 @@ class RestSseNoosphereService { }); Future _respondToChallenge(Request request) => _handleJson( + request, () async { final json = await _readJson(request); final resp = await api.respondToChallenge( @@ -109,13 +111,15 @@ class RestSseNoosphereService { }, ); - Future _extendSession(Request request) => _handleJson(() async { + Future _extendSession(Request request) => + _handleJson(request, () async { final json = await _readJson(request); final resp = await api.extendSession(_sid(_fieldBytes(json, 'sid'))); return _bytesResponse(resp.toBytes()); }); - Future _requestNewDkg(Request request) => _handleEmpty(() async { + Future _requestNewDkg(Request request) => + _handleEmpty(request, () async { final json = await _readJson(request); await api.requestNewDkg( sid: _sid(_fieldBytes(json, 'sid')), @@ -129,7 +133,8 @@ class RestSseNoosphereService { ); }); - Future _rejectDkg(Request request) => _handleEmpty(() async { + Future _rejectDkg(Request request) => + _handleEmpty(request, () async { final json = await _readJson(request); await api.rejectDkg( sid: _sid(_fieldBytes(json, 'sid')), @@ -138,7 +143,7 @@ class RestSseNoosphereService { }); Future _submitDkgCommitment(Request request) => - _handleEmpty(() async { + _handleEmpty(request, () async { final json = await _readJson(request); await api.submitDkgCommitment( sid: _sid(_fieldBytes(json, 'sid')), @@ -149,7 +154,8 @@ class RestSseNoosphereService { ); }); - Future _submitDkgRound2(Request request) => _handleEmpty(() async { + Future _submitDkgRound2(Request request) => + _handleEmpty(request, () async { final json = await _readJson(request); await api.submitDkgRound2( sid: _sid(_fieldBytes(json, 'sid')), @@ -167,7 +173,8 @@ class RestSseNoosphereService { ); }); - Future _sendDkgAcks(Request request) => _handleEmpty(() async { + Future _sendDkgAcks(Request request) => + _handleEmpty(request, () async { final json = await _readJson(request); await api.sendDkgAcks( sid: _sid(_fieldBytes(json, 'sid')), @@ -177,7 +184,8 @@ class RestSseNoosphereService { ); }); - Future _requestDkgAcks(Request request) => _handleJson(() async { + Future _requestDkgAcks(Request request) => + _handleJson(request, () async { final json = await _readJson(request); final resp = await api.requestDkgAcks( sid: _sid(_fieldBytes(json, 'sid')), @@ -189,7 +197,7 @@ class RestSseNoosphereService { }); Future _requestSignatures(Request request) => - _handleEmpty(() async { + _handleEmpty(request, () async { final json = await _readJson(request); await api.requestSignatures( sid: _sid(_fieldBytes(json, 'sid')), @@ -211,7 +219,7 @@ class RestSseNoosphereService { }); Future _rejectSignaturesRequest(Request request) => - _handleEmpty(() async { + _handleEmpty(request, () async { final json = await _readJson(request); await api.rejectSignaturesRequest( sid: _sid(_fieldBytes(json, 'sid')), @@ -220,7 +228,7 @@ class RestSseNoosphereService { }); Future _submitSignatureReplies(Request request) => - _handleJson(() async { + _handleJson(request, () async { final json = await _readJson(request); final resp = await api.submitSignatureReplies( sid: _sid(_fieldBytes(json, 'sid')), @@ -240,7 +248,8 @@ class RestSseNoosphereService { }); }); - Future _shareSecretShare(Request request) => _handleJson(() async { + Future _shareSecretShare(Request request) => + _handleJson(request, () async { final json = await _readJson(request); final resp = await api.shareSecretShare( sid: _sid(_fieldBytes(json, 'sid')), @@ -257,7 +266,7 @@ class RestSseNoosphereService { }); Future _ackKeyConstructed(Request request) => - _handleEmpty(() async { + _handleEmpty(request, () async { final json = await _readJson(request); await api.ackKeyConstructed( sid: _sid(_fieldBytes(json, 'sid')), @@ -280,40 +289,84 @@ class RestSseNoosphereService { }, ); } on InvalidRequest catch (e) { + noosphereRoastServerLogger.w( + "REST ${_requestDescription(request)} rejected: ${e.message}", + ); return _jsonResponse({'error': e.message}, status: 400); } on FormatException catch (e) { + noosphereRoastServerLogger.w( + "REST ${_requestDescription(request)} rejected: ${e.message}", + ); return _jsonResponse({'error': e.message}, status: 400); - } on Exception { + } on Exception catch (e, stackTrace) { + noosphereRoastServerLogger.e( + "REST ${_requestDescription(request)} failed", + error: e, + stackTrace: stackTrace, + ); return _jsonResponse({'error': 'Internal server error'}, status: 500); } } } -Future _handleEmpty(Future Function() action) async { +Future _handleEmpty( + Request request, + Future Function() action, +) async { try { await action(); return _jsonResponse({}); } on InvalidRequest catch (e) { + noosphereRoastServerLogger.w( + "REST ${_requestDescription(request)} rejected: ${e.message}", + ); return _jsonResponse({'error': e.message}, status: 400); } on FormatException catch (e) { + noosphereRoastServerLogger.w( + "REST ${_requestDescription(request)} rejected: ${e.message}", + ); return _jsonResponse({'error': e.message}, status: 400); - } on Exception { + } on Exception catch (e, stackTrace) { + noosphereRoastServerLogger.e( + "REST ${_requestDescription(request)} failed", + error: e, + stackTrace: stackTrace, + ); return _jsonResponse({'error': 'Internal server error'}, status: 500); } } -Future _handleJson(Future Function() action) async { +Future _handleJson( + Request request, + Future Function() action, +) async { try { return await action(); } on InvalidRequest catch (e) { + noosphereRoastServerLogger.w( + "REST ${_requestDescription(request)} rejected: ${e.message}", + ); return _jsonResponse({'error': e.message}, status: 400); } on FormatException catch (e) { + noosphereRoastServerLogger.w( + "REST ${_requestDescription(request)} rejected: ${e.message}", + ); return _jsonResponse({'error': e.message}, status: 400); - } on Exception { + } on Exception catch (e, stackTrace) { + noosphereRoastServerLogger.e( + "REST ${_requestDescription(request)} failed", + error: e, + stackTrace: stackTrace, + ); return _jsonResponse({'error': 'Internal server error'}, status: 500); } } +String _requestDescription(Request request) { + final path = request.url.path.isEmpty ? '/' : '/${request.url.path}'; + return '${request.method} $path'; +} + Future> _readJson(Request request) async { final body = await request.readAsString(); final decoded = jsonDecode(body); diff --git a/lib/src/server/api_handler.dart b/lib/src/server/api_handler.dart index ac1695c..173f48c 100644 --- a/lib/src/server/api_handler.dart +++ b/lib/src/server/api_handler.dart @@ -5,6 +5,7 @@ import 'package:collection/collection.dart'; import 'package:coinlib/coinlib.dart' as cl; import 'package:noosphere_roast_client/noosphere_roast_client.dart'; import 'package:noosphere_roast_server/src/config/server.dart'; +import 'package:noosphere_roast_server/src/logging.dart'; import 'package:noosphere_roast_server/src/server/state/key_sharing.dart'; import 'state/signatures_coordination.dart'; import 'state/client_session.dart'; @@ -18,7 +19,6 @@ import 'state/state.dart'; /// /// The methods should be called sequentially without concurrency. class ServerApiHandler implements ApiRequestInterface { - static const currentProtocolVersion = 2; final ServerConfig config; @@ -46,8 +46,8 @@ class ServerApiHandler implements ApiRequestInterface { return pubkey; } - cl.ECPublicKey _getParticipantPubkeyForSession(ClientSession session) - => _getParticipantPubkeyForId(session.participantId); + cl.ECPublicKey _getParticipantPubkeyForSession(ClientSession session) => + _getParticipantPubkeyForId(session.participantId); void _checkParticipantId(Identifier id) => _getParticipantPubkeyForId(id); @@ -68,7 +68,6 @@ class ServerApiHandler implements ApiRequestInterface { required Identifier participantId, int protocolVersion = currentProtocolVersion, }) async { - // Only allow version 1 if (protocolVersion != currentProtocolVersion) { throw InvalidRequest.invalidProtoVersion(); @@ -90,15 +89,17 @@ class ServerApiHandler implements ApiRequestInterface { expiry: expiry, ); - return ExpirableAuthChallengeResponse(challenge: challenge, expiry: expiry); + noosphereRoastServerLogger.i( + "Issued auth challenge for participant $participantId", + ); + return ExpirableAuthChallengeResponse(challenge: challenge, expiry: expiry); } @override Future respondToChallenge( Signed signedChallenge, ) async { - // Get participant id for challenge and check expiry final details = state.challenges[signedChallenge.obj]; if (details == null) throw InvalidRequest.noChallenge(); @@ -123,9 +124,11 @@ class ServerApiHandler implements ApiRequestInterface { } // Obtain other logged in participants - final online = state.clientSessions.values.map( - (sess) => sess.participantId, - ).toSet(); + final online = state.clientSessions.values + .map( + (sess) => sess.participantId, + ) + .toSet(); // Notify other sessions of login before new session is added state.sendEventToAll(ParticipantStatusEvent(id: pid, loggedIn: true)); @@ -134,23 +137,21 @@ class ServerApiHandler implements ApiRequestInterface { final sessionId = SessionID(); final expiry = Expiry(config.sessionTTL); - final session - = state.participantToSession[pid] - = state.clientSessions[sessionId] - = ClientSession( - participantId: pid, - sessionID: sessionId, - expiry: expiry, - // When the session stream is lost, remove the session and process the - // logout immediately - onLostStream: () { - final sess = state.clientSessions.remove(sessionId); - if (sess != null) { - state.participantToSession.remove(pid); - state.onEndSession(sess); - } - }, - ); + final session = state.participantToSession[pid] = + state.clientSessions[sessionId] = ClientSession( + participantId: pid, + sessionID: sessionId, + expiry: expiry, + // When the session stream is lost, remove the session and process the + // logout immediately + onLostStream: () { + final sess = state.clientSessions.remove(sessionId); + if (sess != null) { + state.participantToSession.remove(pid); + state.onEndSession(sess); + } + }, + ); // If using keepalive, send periodic events if (config.keepAliveFreq != null) { @@ -161,61 +162,68 @@ class ServerApiHandler implements ApiRequestInterface { }); } - return LoginCompleteResponse( + noosphereRoastServerLogger.i("Participant logged in: $pid"); + return LoginCompleteResponse( id: sessionId, expiry: expiry, startTime: startTime, onlineParticipants: online, events: session.eventController.stream, - newDkgs: state.round1Dkgs.map( - (dkg) => NewDkgEvent( - details: dkg.details, - creator: dkg.creator, - commitments: dkg.round1.commitments, - ), - ).toList(), - - sigRequests: state.sigRequests.values.map( - (sig) => SignaturesRequestEvent( - details: sig.details, creator: sig.creator, - ), - ).toList(), + newDkgs: state.round1Dkgs + .map( + (dkg) => NewDkgEvent( + details: dkg.details, + creator: dkg.creator, + commitments: dkg.round1.commitments, + ), + ) + .toList(), + + sigRequests: state.sigRequests.values + .map( + (sig) => SignaturesRequestEvent( + details: sig.details, + creator: sig.creator, + ), + ) + .toList(), // Find rounds that the user is part of and hasn't provided a share yet - sigRounds: state.sigRequests.values.map( - (sigReq) => SignatureNewRoundsEvent( - reqId: sigReq.details.obj.id, - rounds: sigReq.pendingRoundsForId(pid), - ), - ).where((newRounds) => newRounds.rounds.isNotEmpty).toList(), + sigRounds: state.sigRequests.values + .map( + (sigReq) => SignatureNewRoundsEvent( + reqId: sigReq.details.obj.id, + rounds: sigReq.pendingRoundsForId(pid), + ), + ) + .where((newRounds) => newRounds.rounds.isNotEmpty) + .toList(), completedSigs: state.completedSigs.values - .where((sigs) => !sigs.acks.contains(pid)) - .map( - (sigs) => CompletedSignaturesRequest( - details: sigs.details, - signatures: sigs.signatures, - creator: sigs.creator, - ), - ).toList(), + .where((sigs) => !sigs.acks.contains(pid)) + .map( + (sigs) => CompletedSignaturesRequest( + details: sigs.details, + signatures: sigs.signatures, + creator: sigs.creator, + ), + ) + .toList(), secretShares: [ - for ( - final MapEntry(key: groupKey, value: sharingState) - in state.secretShares.entries - ) ...sharingState.getSharesForReceiver(pid).map( - (share) => SecretShareEvent( - sender: share.sender, - keyShare: share.share, - groupKey: groupKey, - ), - ), + for (final MapEntry(key: groupKey, value: sharingState) + in state.secretShares.entries) + ...sharingState.getSharesForReceiver(pid).map( + (share) => SecretShareEvent( + sender: share.sender, + keyShare: share.share, + groupKey: groupKey, + ), + ), ], - ); - } @override @@ -230,7 +238,6 @@ class ServerApiHandler implements ApiRequestInterface { required Signed signedDetails, required DkgPublicCommitment commitment, }) async { - final session = getSession(sid); final details = signedDetails.obj; @@ -241,7 +248,9 @@ class ServerApiHandler implements ApiRequestInterface { // Check expiry is within bounds _verifyExpiry( - details.expiry, config.minDkgRequestTTL, config.maxDkgRequestTTL, + details.expiry, + config.minDkgRequestTTL, + config.maxDkgRequestTTL, ); // Check if name exists in DKG requests already @@ -269,15 +278,23 @@ class ServerApiHandler implements ApiRequestInterface { commitments: commitments, ); + noosphereRoastServerLogger.i( + "DKG requested: name=${details.name} creator=${session.participantId} " + "threshold=${details.threshold}", + ); + // Broadcast to other participants state.sendEventToOthers(dkgEvent, sid); - } @override Future rejectDkg({required SessionID sid, required String name}) async { final participantId = getSession(sid).participantId; if (state.nameToDkg.remove(name) != null) { + noosphereRoastServerLogger.i( + "DKG rejected: name=$name participant=$participantId", + ); + // Send an event to all other participants that the DKG was removed state.sendEventToOthers( DkgRejectEvent(name: name, participant: participantId), @@ -292,7 +309,6 @@ class ServerApiHandler implements ApiRequestInterface { required String name, required DkgPublicCommitment commitment, }) async { - final session = getSession(sid); final pid = session.participantId; @@ -313,6 +329,9 @@ class ServerApiHandler implements ApiRequestInterface { dkg.round = DkgRound2State( expectedHash: dkg.details.obj.hashWithCommitments(commitmentSet), ); + noosphereRoastServerLogger.i( + "DKG advanced to round 2: name=$name commitments=${commitments.length}", + ); } // Send commitment to other participants @@ -320,7 +339,6 @@ class ServerApiHandler implements ApiRequestInterface { DkgCommitmentEvent(name: name, participant: pid, commitment: commitment), sid, ); - } @override @@ -330,19 +348,16 @@ class ServerApiHandler implements ApiRequestInterface { required cl.SchnorrSignature commitmentSetSignature, required Map secrets, }) async { - final session = getSession(sid); final dkg = _getDkg(name); if (dkg.round is! DkgRound2State) throw InvalidRequest.notRound2Dkg(); final round = dkg.round2; // Verify signature - if ( - !commitmentSetSignature.verify( - _getParticipantPubkeyForSession(session), - round.expectedHash, - ) - ) { + if (!commitmentSetSignature.verify( + _getParticipantPubkeyForSession(session), + round.expectedHash, + )) { throw InvalidRequest.invalidDkgCommitmentSetSignature(); } @@ -358,7 +373,6 @@ class ServerApiHandler implements ApiRequestInterface { // Send the signature and secrets to other participants for (final otherSess in state.clientSessions.values) { if (otherSess.sessionID != sid) { - final secret = secrets[otherSess.participantId]; if (secret == null) throw InvalidRequest.invalidSecretMap(); @@ -370,7 +384,6 @@ class ServerApiHandler implements ApiRequestInterface { secret: secret, ), ); - } } @@ -378,13 +391,13 @@ class ServerApiHandler implements ApiRequestInterface { if (round.participantsProvided.length == _participantN - 1) { // Remove DKG state.nameToDkg.remove(name); + noosphereRoastServerLogger.i("DKG completed: name=$name"); // No details of the key are stored on the server as only the participants // can generate the public information at this point. } else { // Record that the participant has provided round 2 round.participantsProvided.add(session.participantId); } - } @override @@ -392,24 +405,20 @@ class ServerApiHandler implements ApiRequestInterface { required SessionID sid, required Set acks, }) async { - getSession(sid); // Verify signatures - if ( - acks.any( - (ack) => !ack.signed.verify(_getParticipantPubkeyForId(ack.signer)), - ) - ) { + if (acks.any( + (ack) => !ack.signed.verify(_getParticipantPubkeyForId(ack.signer)), + )) { throw InvalidRequest.invalidDkgAckSignature(); } final Set newAcks = {}; for (final ack in acks) { - - final ackCache = state.dkgAckCache[ack.signed.obj.groupKey] - ??= DkgAckCache(Expiry(config.ackCacheTTL)); + final ackCache = state.dkgAckCache[ack.signed.obj.groupKey] ??= + DkgAckCache(Expiry(config.ackCacheTTL)); // If ACK already exists in cache, override if changing from false to true // Otherwise do nothing and continue @@ -423,25 +432,26 @@ class ServerApiHandler implements ApiRequestInterface { // Record as new ACK to send newAcks.add(ack); - } // Do not send events if there are no new ACKs if (newAcks.isEmpty) return; + noosphereRoastServerLogger + .i("DKG acknowledgements received: ${newAcks.length}"); + // Send ACKs to participants, ensuring that their own ACKs aren't sent // Do not send to calling participant - for ( - final session in state.clientSessions.values.where( - (s) => s.sessionID != sid, - ) - ) { - final toSend = newAcks.where( - (ack) => ack.signer != session.participantId, - ).toSet(); + for (final session in state.clientSessions.values.where( + (s) => s.sessionID != sid, + )) { + final toSend = newAcks + .where( + (ack) => ack.signer != session.participantId, + ) + .toSet(); if (toSend.isNotEmpty) session.sendEvent(DkgAckEvent(toSend)); } - } @override @@ -449,7 +459,6 @@ class ServerApiHandler implements ApiRequestInterface { required SessionID sid, required Set requests, }) async { - final session = getSession(sid); // Ensure all ids exist @@ -467,7 +476,6 @@ class ServerApiHandler implements ApiRequestInterface { final Set need = {}; for (final request in requests) { - // Get cache for this key final cache = state.dkgAckCache[request.groupPublicKey]; @@ -492,21 +500,24 @@ class ServerApiHandler implements ApiRequestInterface { if (idsToReq.isNotEmpty) { need.add( DkgAckRequest( - ids: idsToReq, groupPublicKey: request.groupPublicKey, + ids: idsToReq, + groupPublicKey: request.groupPublicKey, ), ); } - } if (need.isNotEmpty) { + noosphereRoastServerLogger.d( + "Requested missing DKG acknowledgements: ${need.length}", + ); + // Send DkgAckRequestEvents for missing ACKs state.sendEventToOthers(DkgAckRequestEvent(need), sid); } // Return found ACKS return have; - } @override @@ -516,7 +527,6 @@ class ServerApiHandler implements ApiRequestInterface { required Signed signedDetails, required List commitments, }) async { - final session = getSession(sid); final details = signedDetails.obj; final pid = session.participantId; @@ -528,12 +538,10 @@ class ServerApiHandler implements ApiRequestInterface { } // Require all keys for requested signatures and no more - if ( - !SetEquality().equals( - keys.map((info) => info.groupKey).toSet(), - details.requiredSigs.map((sig) => sig.groupKey).toSet(), - ) - ) { + if (!SetEquality().equals( + keys.map((info) => info.groupKey).toSet(), + details.requiredSigs.map((sig) => sig.groupKey).toSet(), + )) { throw InvalidRequest.wrongSigKeys(); } @@ -555,7 +563,8 @@ class ServerApiHandler implements ApiRequestInterface { } // Create state for request - final reqState = state.sigRequests[details.id] = SignaturesCoordinationState( + final reqState = + state.sigRequests[details.id] = SignaturesCoordinationState( details: signedDetails, creator: pid, keys: keys, @@ -564,7 +573,7 @@ class ServerApiHandler implements ApiRequestInterface { // Add commitments from creator for (int i = 0; i < numSigs; i++) { (reqState.sigs[i] as SingleSignatureInProgressState) - .nextCommitments[pid] = commitments[i]; + .nextCommitments[pid] = commitments[i]; } // Send request event to participants @@ -576,26 +585,31 @@ class ServerApiHandler implements ApiRequestInterface { sid, ); + noosphereRoastServerLogger.i( + "Signatures requested: id=${details.id.toHex()} creator=$pid " + "signatures=$numSigs", + ); } void _checkSigReqFail(SignaturesCoordinationState sigReqState) { - - final malAndRej - = sigReqState.malicious.length + sigReqState.rejectors.length; + final malAndRej = + sigReqState.malicious.length + sigReqState.rejectors.length; final available = _participantN - malAndRej; - final maxThreshold - = sigReqState.sigs - .whereType() - .fold(0, (v, e) => max(v, e.key.group.threshold)); + final maxThreshold = sigReqState.sigs + .whereType() + .fold(0, (v, e) => max(v, e.key.group.threshold)); if (available < maxThreshold) { // Cannot sign one of the signatures as threshold is too high final id = sigReqState.details.obj.id; + noosphereRoastServerLogger.w( + "Signatures request failed: id=${id.toHex()} available=$available " + "required=$maxThreshold", + ); state.sendEventToAll(SignaturesFailureEvent(id)); state.sigRequests.remove(id); } - } @override @@ -603,7 +617,6 @@ class ServerApiHandler implements ApiRequestInterface { required SessionID sid, required SignaturesRequestId reqId, }) async { - final pid = getSession(sid).participantId; final sigReq = state.sigRequests[reqId]; @@ -615,8 +628,10 @@ class ServerApiHandler implements ApiRequestInterface { if (sigReq.malicious.contains(pid)) return; sigReq.rejectors.add(pid); + noosphereRoastServerLogger.i( + "Signatures request rejected: id=${reqId.toHex()} participant=$pid", + ); _checkSigReqFail(sigReq); - } @override @@ -625,7 +640,6 @@ class ServerApiHandler implements ApiRequestInterface { required SignaturesRequestId reqId, required List replies, }) async { - final pid = getSession(sid).participantId; final sigReq = state.sigRequests[reqId]; @@ -636,6 +650,10 @@ class ServerApiHandler implements ApiRequestInterface { void throwMalicious(InvalidRequest exp) { sigReq.malicious.add(pid); + noosphereRoastServerLogger.w( + "Participant marked malicious for signatures request: " + "id=${reqId.toHex()} participant=$pid reason=${exp.message}", + ); _checkSigReqFail(sigReq); throw exp; } @@ -661,7 +679,6 @@ class ServerApiHandler implements ApiRequestInterface { // Loop through provided replies and process for each signature for (final reply in replies) { - final sigI = reply.sigI; if (sigI >= sigDetails.requiredSigs.length) { @@ -702,17 +719,16 @@ class ServerApiHandler implements ApiRequestInterface { ); // ShareVal: validation of provided signature share - if ( - !verifySignatureShare( - commitments: round.commitments, - details: singleSigDetails.signDetails, - id: pid, - share: share, - publicShare: derivedKey.publicShares.list - .firstWhere((share) => share.$1 == pid).$2, - groupKey: derivedKey.groupKey, - ) - ) { + if (!verifySignatureShare( + commitments: round.commitments, + details: singleSigDetails.signDetails, + id: pid, + share: share, + publicShare: derivedKey.publicShares.list + .firstWhere((share) => share.$1 == pid) + .$2, + groupKey: derivedKey.groupKey, + )) { throwMalicious(InvalidRequest.invalidShare()); } @@ -722,7 +738,6 @@ class ServerApiHandler implements ApiRequestInterface { // If all shares have been received, aggregate and complete this // signature if (round.shares.length == threshold) { - final signature = SignatureAggregation( commitments: round.commitments, details: singleSigDetails.signDetails, @@ -730,23 +745,18 @@ class ServerApiHandler implements ApiRequestInterface { info: derivedKey, ).signature; - sigState - = sigReq.sigs[sigI] - = SingleSignatureFinishedState(signature); - + sigState = + sigReq.sigs[sigI] = SingleSignatureFinishedState(signature); } - } // Add next commitment if not already finished if (sigState is SingleSignatureInProgressState) { - final commitments = sigState.nextCommitments; commitments[pid] = reply.nextCommitment; // If we have enough commitments, create new round if (commitments.length == threshold) { - final commitmentSet = SigningCommitmentSet(commitments); final round = SignatureRoundState(commitmentSet); @@ -767,25 +777,22 @@ class ServerApiHandler implements ApiRequestInterface { // Clear next commitments to collect for next round sigState.nextCommitments.clear(); - } - } - } // If all signatures have been completed, submit event and respond with them if (sigReq.sigs.every((sig) => sig is SingleSignatureFinishedState)) { - final signatures = sigReq.sigs - .cast() - .map((sig) => sig.signature).toList(); + .cast() + .map((sig) => sig.signature) + .toList(); // The expiry of the completed signatures should be at least the minimum - final completedExpiry - = sigReq.expiry.ttl < config.minCompletedSignaturesTTL - ? Expiry(config.minCompletedSignaturesTTL) - : sigReq.expiry; + final completedExpiry = + sigReq.expiry.ttl < config.minCompletedSignaturesTTL + ? Expiry(config.minCompletedSignaturesTTL) + : sigReq.expiry; // Store signatures to share with other participants when they are online // and wait to receive enough ACKs before deleting from server. @@ -804,13 +811,21 @@ class ServerApiHandler implements ApiRequestInterface { sid, ); - return SignaturesCompleteResponse(signatures); + noosphereRoastServerLogger.i( + "Signatures request completed: id=${reqId.toHex()} " + "signatures=${signatures.length}", + ); + return SignaturesCompleteResponse(signatures); } // If there are any new rounds, return them and send events to round // participants if (newRounds.isNotEmpty) { + noosphereRoastServerLogger.d( + "Signature rounds started: id=${reqId.toHex()} " + "participants=${newRounds.length}", + ); for (final id in newRounds.keys.where((id) => id != pid)) { state.participantToSession[id]?.sendEvent( @@ -819,12 +834,10 @@ class ServerApiHandler implements ApiRequestInterface { } return SignatureNewRoundsResponse(newRounds[pid]!); - } // Nothing to provide otherwise return null; - } @override @@ -833,7 +846,6 @@ class ServerApiHandler implements ApiRequestInterface { required cl.ECCompressedPublicKey groupKey, required Map encryptedSecrets, }) async { - final session = getSession(sid); final pid = session.participantId; @@ -846,11 +858,9 @@ class ServerApiHandler implements ApiRequestInterface { } // Must contain identifiers in group - if ( - encryptedSecrets.keys.any( - (id) => !config.group.participants.containsKey(id), - ) - ) { + if (encryptedSecrets.keys.any( + (id) => !config.group.participants.containsKey(id), + )) { throw InvalidRequest.invalidKeyShareMap(); } @@ -859,18 +869,24 @@ class ServerApiHandler implements ApiRequestInterface { // events. final secrets = state.secretSharesForKey(groupKey); + var addedShares = 0; - for (final MapEntry(key:id, value:share) in encryptedSecrets.entries) { + for (final MapEntry(key: id, value: share) in encryptedSecrets.entries) { if (secrets.maybeAddShare(pid, id, share)) { + addedShares++; state.participantToSession[id]?.sendEvent( SecretShareEvent(sender: pid, keyShare: share, groupKey: groupKey), ); } } + noosphereRoastServerLogger.i( + "Secret shares received: sender=$pid receivers=${encryptedSecrets.length} " + "new=$addedShares", + ); + // Return cached ConstructedKeyEvents for unneeded secrets return secrets.eventsForCompleted(encryptedSecrets.keys); - } @override @@ -878,7 +894,6 @@ class ServerApiHandler implements ApiRequestInterface { required SessionID sid, required Signed constructedKey, }) async { - final session = getSession(sid); final pid = session.participantId; @@ -905,13 +920,20 @@ class ServerApiHandler implements ApiRequestInterface { // Send event to other participants state.sendEventToOthers(event, sid); + noosphereRoastServerLogger.i( + "Constructed key acknowledged: participant=$pid", + ); } /// Closes all client session streams - Future shutdown() => Future.wait( - state.clientSessions.values.map( - (session) => session.eventController.close(), - ), - ); - + Future shutdown() { + noosphereRoastServerLogger.i( + "Shutting down API handler: sessions=${state.clientSessions.values.length}", + ); + return Future.wait( + state.clientSessions.values.map( + (session) => session.eventController.close(), + ), + ); + } } diff --git a/lib/src/server/state/state.dart b/lib/src/server/state/state.dart index 5cb77c6..dc12db0 100644 --- a/lib/src/server/state/state.dart +++ b/lib/src/server/state/state.dart @@ -1,6 +1,7 @@ import 'package:coinlib/coinlib.dart' as cl; import 'package:noosphere_roast_client/common.dart'; import 'package:noosphere_roast_client/noosphere_roast_client.dart'; +import 'package:noosphere_roast_server/src/logging.dart'; import 'client_session.dart'; import 'dkg.dart'; import 'key_sharing.dart'; @@ -10,7 +11,7 @@ class ChallengeDetails implements Expirable { final Identifier id; @override final Expiry expiry; - ChallengeDetails({ required this.id, required this.expiry }); + ChallengeDetails({required this.id, required this.expiry}); } /// Caches DKG acknowledgements to support sharing amongst participants. The @@ -28,6 +29,7 @@ class CompletedSignatures implements Expirable { final Signed details; final List signatures; final Identifier creator; + /// This is not set, but in the future can contain acknowledgements from /// participants when they have received the signature so that they do not /// receive it again and so that signatures can be removed when enough @@ -44,18 +46,16 @@ class CompletedSignatures implements Expirable { } class ServerState { - final challenges = ExpirableMap(); late final ExpirableMap clientSessions; final participantToSession = ExpirableMap(); final nameToDkg = ExpirableMap(); final dkgAckCache = ExpirableMap(); - final sigRequests = ExpirableMap< - SignaturesRequestId, SignaturesCoordinationState - >(); - final completedSigs = ExpirableMap< - SignaturesRequestId, CompletedSignatures - >(); + final sigRequests = + ExpirableMap(); + final completedSigs = + ExpirableMap(); + /// Maps the encrypted secret shares to a FROST group key for sharing to /// participants final Map secretShares = {}; @@ -67,6 +67,9 @@ class ServerState { } void onEndSession(ClientSession session) { + noosphereRoastServerLogger.i( + "Participant session ended: ${session.participantId}", + ); // Reset DKGs to round 1 as all participants need to remain online to // complete them @@ -86,23 +89,21 @@ class ServerState { sendEventToAll( ParticipantStatusEvent(id: session.participantId, loggedIn: false), ); - } Iterable get round1Dkgs => nameToDkg.values.where( - (dkg) => dkg.round is DkgRound1State, - ); + (dkg) => dkg.round is DkgRound1State, + ); - void sendEventToAll(Event e, { List exclude = const [] }) { + void sendEventToAll(Event e, {List exclude = const []}) { for (final session in clientSessions.values) { if (!exclude.contains(session.sessionID)) session.sendEvent(e); } } - void sendEventToOthers(Event e, SessionID sid) - => sendEventToAll(e, exclude: [sid]); - - KeySharingState secretSharesForKey(cl.ECCompressedPublicKey key) - => secretShares[key] ??= KeySharingState(); + void sendEventToOthers(Event e, SessionID sid) => + sendEventToAll(e, exclude: [sid]); + KeySharingState secretSharesForKey(cl.ECCompressedPublicKey key) => + secretShares[key] ??= KeySharingState(); } diff --git a/pubspec.yaml b/pubspec.yaml index 818c215..3ed7c6f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: args: ^2.6.0 shelf: ^1.4.2 shelf_router: ^1.1.4 + logger: ^2.7.0 dev_dependencies: lints: ^6.0.0 diff --git a/test/rest_test.dart b/test/rest_test.dart index fa49753..e502829 100644 --- a/test/rest_test.dart +++ b/test/rest_test.dart @@ -121,8 +121,10 @@ void main() { expect(response.statusCode, 200); expect( - Expiry.fromBytes(_dataBytes(await response.readAsString())).ttl, - api.expiry.ttl, + Expiry.fromBytes(_dataBytes(await response.readAsString())) + .time + .millisecondsSinceEpoch, + api.expiry.time.millisecondsSinceEpoch, ); }); From e5178aa64862173c882aa48810aec4a82e2aa09e Mon Sep 17 00:00:00 2001 From: peerchemist Date: Mon, 15 Jun 2026 17:13:56 +0200 Subject: [PATCH 05/25] Add CLI log level option --- README.md | 9 +++++++++ bin/grpc_server.dart | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/README.md b/README.md index c027f13..06b8f6b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,15 @@ A server can be run from a given `GrpcConfig` YAML file using `dart run noosphere_roast_server:grpc_server --config your_config_file_here.yaml`. Alternatively a server may be created using the package as a library. +The server emits `info` logs by default. Use `--log-level` to choose one of +`trace`, `debug`, `info`, `warning`, `error`, `fatal`, or `off`: + +```sh +dart run noosphere_roast_server:grpc_server \ + --config your_config_file_here.yaml \ + --log-level debug +``` + ## Podman / Docker Build the image from this repository: diff --git a/bin/grpc_server.dart b/bin/grpc_server.dart index 48cdb8e..d9385e4 100644 --- a/bin/grpc_server.dart +++ b/bin/grpc_server.dart @@ -4,6 +4,16 @@ import 'package:args/args.dart'; import 'package:coinlib/coinlib.dart'; import 'package:noosphere_roast_server/noosphere_roast_server.dart'; +const _logLevels = { + "trace": Level.trace, + "debug": Level.debug, + "info": Level.info, + "warning": Level.warning, + "error": Level.error, + "fatal": Level.fatal, + "off": Level.off, +}; + void main(List args) async { final argParser = ArgParser(); argParser.addOption( @@ -21,7 +31,16 @@ void main(List args) async { help: "CORS Access-Control-Allow-Origin value for REST/SSE clients", defaultsTo: "*", ); + argParser.addOption( + "log-level", + help: "Minimum log level to emit", + allowed: _logLevels.keys, + defaultsTo: "info", + ); final argResults = argParser.parse(args); + configureNoosphereRoastServerLogging( + level: _logLevels[argResults.option("log-level")]!, + ); final configFile = argResults.option("config")!; final restPortString = argResults.option("rest-port"); final restPort = restPortString == null ? null : int.parse(restPortString); From 67980f09627897f88aa2ddfec160aa25ea210e6c Mon Sep 17 00:00:00 2001 From: peerchemist Date: Tue, 16 Jun 2026 12:22:09 +0200 Subject: [PATCH 06/25] Enable REST API in Docker --- Dockerfile | 4 +- README.md | 8 +- REST_API_SPEC.md | 388 +++++++++++++++++++++++++++++++++++++++++++ bin/grpc_server.dart | 10 +- 4 files changed, 405 insertions(+), 5 deletions(-) create mode 100644 REST_API_SPEC.md diff --git a/Dockerfile b/Dockerfile index 7f34e52..7c9d33c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -79,7 +79,7 @@ COPY --from=frosty-build /out/libfrosty_rust.so /app/build/libfrosty_rust.so COPY --from=secp256k1-build /out/libsecp256k1.so /app/build/libsecp256k1.so ENV LD_LIBRARY_PATH="/app/build:/usr/local/lib" -EXPOSE 50051 +EXPOSE 50051 8080 ENTRYPOINT ["dart", "run", "noosphere_roast_server:grpc_server", "--config"] -CMD ["/config/server.yaml"] +CMD ["/config/server.yaml", "--rest-address", "0.0.0.0", "--rest-port", "8080"] diff --git a/README.md b/README.md index 06b8f6b..f8591c0 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,14 @@ Run the server with a mounted YAML configuration: ```sh podman run --rm \ -p 50051:50051 \ + -p 8080:8080 \ -v "$PWD/config.yaml:/config/server.yaml:ro,Z" \ noosphere-roast-server ``` +The container starts both gRPC and REST/SSE by default. gRPC listens on the port +from the YAML config, and REST/SSE listens on container port `8080`. + The `:Z` suffix relabels the mounted config file so Podman can read it on SELinux-enforcing hosts. Use `:z` instead if the same config file must be shared by multiple containers. @@ -55,8 +59,10 @@ To use a different in-container config path, pass it as the command: ```sh podman run --rm \ -p 50051:50051 \ + -p 8080:8080 \ -v "$PWD/config.yaml:/app/config.yaml:ro,Z" \ - noosphere-roast-server /app/config.yaml + noosphere-roast-server \ + /app/config.yaml --rest-address 0.0.0.0 --rest-port 8080 ``` The image builds the `frosty` and `secp256k1-coinlib` native libraries during diff --git a/REST_API_SPEC.md b/REST_API_SPEC.md new file mode 100644 index 0000000..c14b1a0 --- /dev/null +++ b/REST_API_SPEC.md @@ -0,0 +1,388 @@ +# Noosphere ROAST Server REST/SSE API + +This specification is for implementing a frontend REST/SSE adapter for the +Noosphere ROAST server. The same frontend may already support the gRPC endpoint; +reuse the same Noosphere domain serializers and parsers where possible. + +## Transport Model + +Base URL: the REST server origin configured separately from gRPC with +`--rest-port`. + +All `POST` endpoints: + +- Request body is JSON. +- Request header should include `Content-Type: application/json`. +- Binary/domain objects are base64 strings of the same `.toBytes()` payloads + used by the gRPC client. +- The server accepts standard base64 or URL-safe base64, with or without + padding. +- Do not send gRPC/protobuf wrapper messages to REST; send the underlying + domain bytes encoded as base64. + +Success response shapes: + +```json +{} +``` + +```json +{ "data": "" } +``` + +```json +{ "data": ["", "..."] } +``` + +Error response shapes: + +```json +{ "error": "" } +``` + +Invalid requests return HTTP `400`. Unexpected server errors return HTTP `500` +with `{ "error": "Internal server error" }`. + +## Endpoints + +### POST /login + +Request: + +```json +{ + "groupFingerprint": "", + "participantId": "", + "protocolVersion": 2 +} +``` + +`protocolVersion` is optional and defaults to `2`. + +Response: + +```json +{ "data": "" } +``` + +### POST /respond-to-challenge + +Request: + +```json +{ + "challenge": "", + "signature": "" +} +``` + +Response: + +```json +{ "data": "" } +``` + +Use the decoded `LoginCompleteResponse.id` as the session id for later calls +and SSE. + +### POST /extend-session + +Request: + +```json +{ "sid": "" } +``` + +Response: + +```json +{ "data": "" } +``` + +### POST /dkg/new + +Request: + +```json +{ + "sid": "", + "signedDetails": " bytes>", + "commitment": "" +} +``` + +Response: + +```json +{} +``` + +### POST /dkg/reject + +Request: + +```json +{ + "sid": "", + "name": "dkg-name" +} +``` + +Response: + +```json +{} +``` + +### POST /dkg/commitment + +Request: + +```json +{ + "sid": "", + "name": "dkg-name", + "commitment": "" +} +``` + +Response: + +```json +{} +``` + +### POST /dkg/round2 + +Request: + +```json +{ + "sid": "", + "name": "dkg-name", + "commitmentSetSignature": "", + "secrets": [ + { + "id": "", + "secret": "" + } + ] +} +``` + +Response: + +```json +{} +``` + +### POST /dkg/acks + +Request: + +```json +{ + "sid": "", + "acks": [""] +} +``` + +Response: + +```json +{} +``` + +### POST /dkg/request-acks + +Request: + +```json +{ + "sid": "", + "requests": [""] +} +``` + +Response: + +```json +{ "data": [""] } +``` + +### POST /signatures/request + +Request: + +```json +{ + "sid": "", + "keys": [""], + "signedDetails": " bytes>", + "commitments": [""] +} +``` + +Response: + +```json +{} +``` + +### POST /signatures/reject + +Request: + +```json +{ + "sid": "", + "reqId": "" +} +``` + +Response: + +```json +{} +``` + +### POST /signatures/replies + +Request: + +```json +{ + "sid": "", + "reqId": "", + "replies": [""] +} +``` + +Response when new rounds are created: + +```json +{ + "type": "new_round", + "data": "" +} +``` + +Response when signatures are complete: + +```json +{ + "type": "complete", + "data": "" +} +``` + +Response when there is no immediate data: + +```json +{ + "type": "empty", + "data": null +} +``` + +### POST /secret-share + +Request: + +```json +{ + "sid": "", + "groupKey": "", + "secrets": [ + { + "id": "", + "share": "" + } + ] +} +``` + +Response: + +```json +{ "data": [""] } +``` + +### POST /key-constructed/ack + +Request: + +```json +{ + "sid": "", + "constructedKey": " bytes>" +} +``` + +Response: + +```json +{} +``` + +## SSE Event Stream + +Open after login: + +```text +GET /sessions//events +``` + +`` is the raw `SessionID.n` bytes encoded as URL-safe base64 with padding +removed. + +Response headers include: + +```text +content-type: text/event-stream +cache-control: no-cache +x-accel-buffering: no +``` + +Each SSE message: + +```text +event: +data: + +``` + +Event type mapping: + +```text +participant_status +new_dkg +dkg_commitment +dkg_reject +dkg_round2_share +dkg_ack +dkg_ack_request +signatures_request +signature_new_rounds +signatures_complete +signatures_failure +secret_share +constructed_key +keepalive +``` + +## Frontend Adapter Guidance + +Implement a REST adapter with the same method surface as the existing gRPC +adapter. Most methods should be thin wrappers: + +1. Serialize existing Noosphere domain objects to bytes. +2. Base64 encode those bytes into the documented JSON fields. +3. `POST` the JSON request. +4. Decode returned `data` bytes back into the same domain response classes. +5. For SSE, route by the `event` name and parse `data` as the matching Event + bytes. + +The REST transport does not replace client-side protocol logic. DKG, +signature-round, authentication, and key-sharing behavior should remain the +same as in the gRPC adapter. diff --git a/bin/grpc_server.dart b/bin/grpc_server.dart index d9385e4..0078a88 100644 --- a/bin/grpc_server.dart +++ b/bin/grpc_server.dart @@ -26,6 +26,11 @@ void main(List args) async { "rest-port", help: "Optional REST/SSE port for browser clients", ); + argParser.addOption( + "rest-address", + help: "REST/SSE bind address", + defaultsTo: "localhost", + ); argParser.addOption( "rest-allow-origin", help: "CORS Access-Control-Allow-Origin value for REST/SSE clients", @@ -66,9 +71,10 @@ void main(List args) async { api: apiHandler, allowOrigin: argResults.option("rest-allow-origin")!, ); - restServer = await restService.serve(port: restPort); + final restAddress = argResults.option("rest-address")!; + restServer = await restService.serve(address: restAddress, port: restPort); noosphereRoastServerLogger.i( - "REST/SSE server listening on port ${restServer.port}", + "REST/SSE server listening on $restAddress:${restServer.port}", ); } From e521c4b10fa6b6e65c0eba99ba6b47b5f266c5ef Mon Sep 17 00:00:00 2001 From: peerchemist Date: Tue, 16 Jun 2026 18:30:52 +0200 Subject: [PATCH 07/25] Add gRPC access debug logs --- lib/src/grpc.dart | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/src/grpc.dart b/lib/src/grpc.dart index 1f76c39..cefe8ba 100644 --- a/lib/src/grpc.dart +++ b/lib/src/grpc.dart @@ -44,8 +44,11 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { String method, Future Function() f, ) async { + noosphereRoastServerLogger.d("gRPC $method received"); try { - return await f(); + final result = await f(); + noosphereRoastServerLogger.d("gRPC $method completed"); + return result; } on Exception catch (e, stackTrace) { throw _wrapException(method, e, stackTrace); } @@ -105,16 +108,32 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { throw _wrapException("fetchEventStream", e, stackTrace); } + noosphereRoastServerLogger.d( + "gRPC fetchEventStream opened for participant ${session.participantId}", + ); + // sendTrailers is not always called automatically when the stream ends // despite the documentation. // Without calling this, the grpc stream may hang and never close. final controller = StreamController( - onCancel: () => call.sendTrailers(), + onCancel: () { + noosphereRoastServerLogger.d( + "gRPC fetchEventStream canceled for participant " + "${session.participantId}", + ); + call.sendTrailers(); + }, ); // When upstream stream is done, cancel this one controller.addStream(session.eventController.stream).then( - (_) => controller.close(), + (_) { + noosphereRoastServerLogger.d( + "gRPC fetchEventStream closed for participant " + "${session.participantId}", ); + return controller.close(); + }, + ); // Pass across all events return controller.stream.map( From 3ae2ba2e499fff91c725dad4cf040144fcca64a8 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Tue, 16 Jun 2026 19:10:52 +0200 Subject: [PATCH 08/25] Document REST deployment and logging --- README.md | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 134 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f8591c0..b66c0cd 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,25 @@ dart run noosphere_roast_server:grpc_server \ --log-level debug ``` +REST/SSE can be enabled for browser clients with `--rest-port`: + +```sh +dart run noosphere_roast_server:grpc_server \ + --config your_config_file_here.yaml \ + --rest-port 8080 \ + --rest-allow-origin '*' +``` + +`--rest-allow-origin '*'` is convenient for local testing, but production +deployments should set `--rest-allow-origin` to the exact frontend origin that +will access the REST/SSE API, for example `https://app.example.com`. + +Use `--rest-address 0.0.0.0` when the REST/SSE listener must be reachable from +outside the process namespace, such as from a container port mapping. The +default REST/SSE bind address is `localhost`. + +The REST/SSE API shape is documented in [REST_API_SPEC.md](REST_API_SPEC.md). + ## Podman / Docker Build the image from this repository: @@ -25,6 +44,10 @@ Build the image from this repository: podman build -t noosphere-roast-server . ``` +Rebuild the image after changing local source. The Dockerfile copies this local +repository into the image with `COPY . .`, so an old image will not contain +recent CLI, logging, or REST changes. + The Dockerfile builds `libfrosty_rust.so` from `peercoin/frosty` `v3.0.0`, matching the current `frosty` dependency, and `libsecp256k1.so` from `peercoin/secp256k1-coinlib` `v0.7.0`, matching the current `coinlib` @@ -50,6 +73,11 @@ podman run --rm \ The container starts both gRPC and REST/SSE by default. gRPC listens on the port from the YAML config, and REST/SSE listens on container port `8080`. +Port mapping syntax is `host_port:container_port`. If the YAML config says +`port: 443`, the gRPC server listens on container port `443`, so map it with +`-p 50051:443` if clients should connect to host port `50051`. If the YAML +config says `port: 50051`, use `-p 50051:50051`. + The `:Z` suffix relabels the mounted config file so Podman can read it on SELinux-enforcing hosts. Use `:z` instead if the same config file must be shared by multiple containers. @@ -65,9 +93,112 @@ podman run --rm \ /app/config.yaml --rest-address 0.0.0.0 --rest-port 8080 ``` -The image builds the `frosty` and `secp256k1-coinlib` native libraries during -the container build and copies `libfrosty_rust.so` and `libsecp256k1.so` into -`/app/build`. +### REST/SSE With CORS + +For local testing, allow any browser origin and enable debug logs: + +```sh +podman run --rm \ + -p 50051:50051 \ + -p 8080:8080 \ + -v "$PWD/config.yaml:/config/server.yaml:ro,Z" \ + noosphere-roast-server \ + /config/server.yaml \ + --rest-address 0.0.0.0 \ + --rest-port 8080 \ + --rest-allow-origin '*' \ + --log-level debug +``` + +For production, replace `'*'` with the frontend origin that loads the web app: + +```sh +--rest-allow-origin https://app.example.com +``` + +### Caddy Reverse Proxy + +Bind container ports to localhost when Caddy runs on the same host: + +```sh +podman run --rm \ + -p 127.0.0.1:50051:50051 \ + -p 127.0.0.1:8080:8080 \ + -v "$PWD/config.yaml:/config/server.yaml:ro,Z" \ + noosphere-roast-server \ + /config/server.yaml \ + --rest-address 0.0.0.0 \ + --rest-port 8080 \ + --rest-allow-origin https://app.example.com \ + --log-level info +``` + +REST/SSE on a dedicated API hostname: + +```caddyfile +api.example.com { + reverse_proxy 127.0.0.1:8080 { + flush_interval -1 + } +} +``` + +If the browser frontend is served from the same hostname and REST is under a +prefix, strip the prefix before proxying: + +```caddyfile +app.example.com { + handle_path /api/noosphere/* { + reverse_proxy 127.0.0.1:8080 { + flush_interval -1 + } + } + + root * /srv/app + file_server +} +``` + +### Ngrok For REST/SSE Testing + +Expose the REST/SSE port, not the gRPC port: + +```sh +ngrok http 8080 +``` + +Use the printed HTTPS URL as the REST base URL in the frontend. The SSE stream +will be under: + +```text +https:///sessions//events +``` + +### Logging + +Use `--log-level debug` when diagnosing frontend connectivity: + +```sh +--log-level debug +``` + +At `info`, the server logs lifecycle and coordinator state changes such as +startup, auth challenges, participant login/logout, DKG requests, signature +completion, and shutdown. + +At `debug`, the gRPC transport also logs request receipt/completion and event +stream lifecycle, for example: + +```text +gRPC login received +gRPC login completed +gRPC fetchEventStream opened for participant ... +``` + +If shared coordinator logs appear but no `gRPC ... received` logs appear while +running with `--log-level debug`, the frontend is probably using REST or the +gRPC request is not reaching this container. Check the configured client port, +container port mapping, firewall, and any reverse proxy. The same commands also work with Docker by replacing `podman` with `docker`. From 614ac8903790b92609071133ee70d4c8ee7bf925 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Tue, 16 Jun 2026 19:22:06 +0200 Subject: [PATCH 09/25] Slim dockerfile --- Dockerfile | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7c9d33c..9a01980 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG FROSTY_VERSION=v3.0.0 +ARG FROSTY_VERSION=v4.0.0 ARG SECP256K1_COINLIB_VERSION=0.7.0 FROM docker.io/library/debian:bookworm AS secp256k1-build @@ -64,7 +64,7 @@ RUN cargo build --release \ && mkdir -p /out \ && cp target/release/libfrosty_rust.so /out/libfrosty_rust.so -FROM docker.io/library/dart:stable +FROM docker.io/library/dart:stable AS dart-build WORKDIR /app @@ -74,12 +74,24 @@ RUN dart pub get COPY . . RUN dart pub get --offline +RUN dart compile exe bin/grpc_server.dart -o /out/noosphere_roast_server +FROM docker.io/library/debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + libgcc-s1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=dart-build /out/noosphere_roast_server /app/noosphere_roast_server COPY --from=frosty-build /out/libfrosty_rust.so /app/build/libfrosty_rust.so COPY --from=secp256k1-build /out/libsecp256k1.so /app/build/libsecp256k1.so ENV LD_LIBRARY_PATH="/app/build:/usr/local/lib" EXPOSE 50051 8080 -ENTRYPOINT ["dart", "run", "noosphere_roast_server:grpc_server", "--config"] +ENTRYPOINT ["/app/noosphere_roast_server", "--config"] CMD ["/config/server.yaml", "--rest-address", "0.0.0.0", "--rest-port", "8080"] From 06611f85fc26862e31a36eeb61be5da1fe75dff2 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Wed, 17 Jun 2026 11:55:11 +0200 Subject: [PATCH 10/25] Add debug logging for REST requests --- lib/src/rest.dart | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/lib/src/rest.dart b/lib/src/rest.dart index 78af7c0..ef321b7 100644 --- a/lib/src/rest.dart +++ b/lib/src/rest.dart @@ -278,8 +278,11 @@ class RestSseNoosphereService { }); Future _fetchEventStream(Request request, String sid) async { + final description = _requestDescription(request); + noosphereRoastServerLogger.d("REST $description received"); try { final session = api.getSession(_sid(_decodeBytes(sid))); + noosphereRoastServerLogger.d("REST $description opened"); return Response.ok( session.eventController.stream.map(_sseEvent), headers: { @@ -290,17 +293,17 @@ class RestSseNoosphereService { ); } on InvalidRequest catch (e) { noosphereRoastServerLogger.w( - "REST ${_requestDescription(request)} rejected: ${e.message}", + "REST $description rejected: ${e.message}", ); return _jsonResponse({'error': e.message}, status: 400); } on FormatException catch (e) { noosphereRoastServerLogger.w( - "REST ${_requestDescription(request)} rejected: ${e.message}", + "REST $description rejected: ${e.message}", ); return _jsonResponse({'error': e.message}, status: 400); } on Exception catch (e, stackTrace) { noosphereRoastServerLogger.e( - "REST ${_requestDescription(request)} failed", + "REST $description failed", error: e, stackTrace: stackTrace, ); @@ -313,22 +316,25 @@ Future _handleEmpty( Request request, Future Function() action, ) async { + final description = _requestDescription(request); + noosphereRoastServerLogger.d("REST $description received"); try { await action(); + noosphereRoastServerLogger.d("REST $description completed"); return _jsonResponse({}); } on InvalidRequest catch (e) { noosphereRoastServerLogger.w( - "REST ${_requestDescription(request)} rejected: ${e.message}", + "REST $description rejected: ${e.message}", ); return _jsonResponse({'error': e.message}, status: 400); } on FormatException catch (e) { noosphereRoastServerLogger.w( - "REST ${_requestDescription(request)} rejected: ${e.message}", + "REST $description rejected: ${e.message}", ); return _jsonResponse({'error': e.message}, status: 400); } on Exception catch (e, stackTrace) { noosphereRoastServerLogger.e( - "REST ${_requestDescription(request)} failed", + "REST $description failed", error: e, stackTrace: stackTrace, ); @@ -340,21 +346,25 @@ Future _handleJson( Request request, Future Function() action, ) async { + final description = _requestDescription(request); + noosphereRoastServerLogger.d("REST $description received"); try { - return await action(); + final response = await action(); + noosphereRoastServerLogger.d("REST $description completed"); + return response; } on InvalidRequest catch (e) { noosphereRoastServerLogger.w( - "REST ${_requestDescription(request)} rejected: ${e.message}", + "REST $description rejected: ${e.message}", ); return _jsonResponse({'error': e.message}, status: 400); } on FormatException catch (e) { noosphereRoastServerLogger.w( - "REST ${_requestDescription(request)} rejected: ${e.message}", + "REST $description rejected: ${e.message}", ); return _jsonResponse({'error': e.message}, status: 400); } on Exception catch (e, stackTrace) { noosphereRoastServerLogger.e( - "REST ${_requestDescription(request)} failed", + "REST $description failed", error: e, stackTrace: stackTrace, ); From a9286f30611da62392669135414f0590b9f948a0 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Wed, 17 Jun 2026 12:04:22 +0200 Subject: [PATCH 11/25] Create Docker output directory before compile --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9a01980..c76fa70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,7 +74,8 @@ RUN dart pub get COPY . . RUN dart pub get --offline -RUN dart compile exe bin/grpc_server.dart -o /out/noosphere_roast_server +RUN mkdir -p /out \ + && dart compile exe bin/grpc_server.dart -o /out/noosphere_roast_server FROM docker.io/library/debian:bookworm-slim From f7da65be9154a165309375e3992eff99d3463a06 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Wed, 17 Jun 2026 12:08:58 +0200 Subject: [PATCH 12/25] Default gRPC config port to 50051 --- README.md | 5 +++-- lib/src/config/grpc.dart | 32 ++++++++++++++++--------------- test/config_test.dart | 41 ++++++++++++++++++++++++++++------------ 3 files changed, 49 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index b66c0cd..12e6534 100644 --- a/README.md +++ b/README.md @@ -71,12 +71,13 @@ podman run --rm \ ``` The container starts both gRPC and REST/SSE by default. gRPC listens on the port -from the YAML config, and REST/SSE listens on container port `8080`. +from the YAML config, or `50051` when `port` is omitted. REST/SSE listens on +container port `8080`. Port mapping syntax is `host_port:container_port`. If the YAML config says `port: 443`, the gRPC server listens on container port `443`, so map it with `-p 50051:443` if clients should connect to host port `50051`. If the YAML -config says `port: 50051`, use `-p 50051:50051`. +config omits `port` or says `port: 50051`, use `-p 50051:50051`. The `:Z` suffix relabels the mounted config file so Podman can read it on SELinux-enforcing hosts. Use `:z` instead if the same config file must be diff --git a/lib/src/config/grpc.dart b/lib/src/config/grpc.dart index 61f209f..7750f83 100644 --- a/lib/src/config/grpc.dart +++ b/lib/src/config/grpc.dart @@ -4,34 +4,37 @@ import 'package:noosphere_roast_client/noosphere_roast_client.dart'; import 'server.dart'; class GrpcConfig with cl.Writable, MapWritable { + static const defaultPort = 50051; final ServerConfig server; final int port; GrpcConfig({ required this.server, - required this.port, + this.port = defaultPort, }); - GrpcConfig.fromReader(cl.BytesReader reader) : this( - server: ServerConfig.fromReader(reader), - port: reader.readUInt16(), - ); + GrpcConfig.fromReader(cl.BytesReader reader) + : this( + server: ServerConfig.fromReader(reader), + port: reader.readUInt16(), + ); /// Convenience constructor to construct from serialised [bytes]. GrpcConfig.fromBytes(Uint8List bytes) - : this.fromReader(cl.BytesReader(bytes)); + : this.fromReader(cl.BytesReader(bytes)); /// Convenience constructor to construct from encoded [hex]. GrpcConfig.fromHex(String hex) : this.fromBytes(cl.hexToBytes(hex)); - GrpcConfig.fromMapReader(MapReader reader) : this( - server: ServerConfig.fromMapReader(reader["server"]), - port: reader["port"].require(), - ); + GrpcConfig.fromMapReader(MapReader reader) + : this( + server: ServerConfig.fromMapReader(reader["server"]), + port: reader["port"].value() ?? defaultPort, + ); GrpcConfig.fromYaml(String yaml) - : this.fromMapReader(MapReader.fromYaml(yaml)); + : this.fromMapReader(MapReader.fromYaml(yaml)); @override void write(cl.Writer writer) { @@ -41,8 +44,7 @@ class GrpcConfig with cl.Writable, MapWritable { @override Map map() => { - "port": port, - "server": server.map(), - }; - + "port": port, + "server": server.map(), + }; } diff --git a/test/config_test.dart b/test/config_test.dart index 0512447..16cada0 100644 --- a/test/config_test.dart +++ b/test/config_test.dart @@ -3,26 +3,36 @@ import 'package:test/test.dart'; import 'data.dart'; import 'helpers.dart'; -final String id1 = "000000000000000000000000000000000000000000000000000000000000000a"; -final String id2 = "000000000000000000000000000000000000000000000000000000000000000b"; -final String key1 = "02774ae7f858a9411e5ef4246b70c65aac5649980be5c17891bbec17895da008cb"; -final String key2 = "03a0434d9e47f3c86235477c7b1ae6ae5d3442d49b1943c2b752a68e2a47e247c7"; +final String id1 = + "000000000000000000000000000000000000000000000000000000000000000a"; +final String id2 = + "000000000000000000000000000000000000000000000000000000000000000b"; +final String key1 = + "02774ae7f858a9411e5ef4246b70c65aac5649980be5c17891bbec17895da008cb"; +final String key2 = + "03a0434d9e47f3c86235477c7b1ae6ae5d3442d49b1943c2b752a68e2a47e247c7"; void yamlTest( T Function() getWritable, T Function(String) fromYaml, String Function(T) toHex, -) => test("read/write yaml", () { - final writable = getWritable(); - expect(fromYaml(writable.yaml).yaml, writable.yaml); - // Expect bytes to be the same after YAML conversion - expect(toHex(writable), toHex(fromYaml(writable.yaml))); -}); +) => + test("read/write yaml", () { + final writable = getWritable(); + expect(fromYaml(writable.yaml).yaml, writable.yaml); + // Expect bytes to be the same after YAML conversion + expect(toHex(writable), toHex(fromYaml(writable.yaml))); + }); final grpcConfig = GrpcConfig(server: serverConfig, port: 80); -void main() { +String _indentYaml(String yaml) => yaml + .split('\n') + .where((line) => line.isNotEmpty) + .map((line) => ' $line') + .join('\n'); +void main() { setUpAll(loadFrosty); group("ServerConfig", () { @@ -44,6 +54,13 @@ void main() { (yaml) => GrpcConfig.fromYaml(yaml), (config) => config.toHex(), ); - }); + test("defaults to port 50051 when YAML port is omitted", () { + final config = GrpcConfig.fromYaml( + 'server:\n${_indentYaml(serverConfig.yaml)}\n', + ); + + expect(config.port, GrpcConfig.defaultPort); + }); + }); } From cde17d01b7d90d4e94551c0a71110175e5ace068 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Wed, 17 Jun 2026 12:16:38 +0200 Subject: [PATCH 13/25] Add SSE event propagation diagnostics --- lib/src/rest.dart | 12 ++++++--- lib/src/server/state/state.dart | 12 +++++++-- test/rest_test.dart | 43 ++++++++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/lib/src/rest.dart b/lib/src/rest.dart index ef321b7..cbb052d 100644 --- a/lib/src/rest.dart +++ b/lib/src/rest.dart @@ -442,10 +442,14 @@ List _fieldStringList(Map json, String name) { }).toList(); } -List _sseEvent(Event event) => utf8.encode( - 'event: ${_eventType(event)}\n' - 'data: ${_encodeBytes(event.toBytes())}\n\n', - ); +List _sseEvent(Event event) { + final type = _eventType(event); + noosphereRoastServerLogger.d("REST SSE sent $type"); + return utf8.encode( + 'event: $type\n' + 'data: ${_encodeBytes(event.toBytes())}\n\n', + ); +} String _eventType(Event event) => switch (event) { ParticipantStatusEvent() => 'participant_status', diff --git a/lib/src/server/state/state.dart b/lib/src/server/state/state.dart index dc12db0..d82fd67 100644 --- a/lib/src/server/state/state.dart +++ b/lib/src/server/state/state.dart @@ -96,8 +96,16 @@ class ServerState { ); void sendEventToAll(Event e, {List exclude = const []}) { - for (final session in clientSessions.values) { - if (!exclude.contains(session.sessionID)) session.sendEvent(e); + final sessions = clientSessions.values.toList(); + final recipients = sessions + .where((session) => !exclude.contains(session.sessionID)) + .toList(); + noosphereRoastServerLogger.d( + "Broadcasting ${e.runtimeType} to ${recipients.length}/" + "${sessions.length} sessions", + ); + for (final session in recipients) { + session.sendEvent(e); } } diff --git a/test/rest_test.dart b/test/rest_test.dart index e502829..94ce340 100644 --- a/test/rest_test.dart +++ b/test/rest_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:noosphere_roast_server/noosphere_roast_server.dart'; import 'package:noosphere_roast_server/src/server/state/client_session.dart'; +import 'package:noosphere_roast_server/src/server/state/state.dart'; import 'package:shelf/shelf.dart'; import 'package:test/test.dart'; @@ -37,13 +38,27 @@ SessionID _sid([int lastByte = 1]) => SessionID.fromBytes(Uint8List(16)..last = lastByte); class _FakeSession implements ClientSession { + @override + final SessionID sessionID; + + @override + final Expiry expiry; + @override final StreamController eventController; - _FakeSession({void Function()? onCancel}) - : eventController = StreamController(onCancel: onCancel); + _FakeSession({ + SessionID? sid, + Expiry? expiry, + void Function()? onCancel, + }) : sessionID = sid ?? _sid(), + expiry = expiry ?? Expiry(Duration(minutes: 5)), + eventController = StreamController(onCancel: onCancel); - void send(Event event) => eventController.add(event); + void send(Event event) => sendEvent(event); + + @override + void sendEvent(Event event) => eventController.add(event); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); @@ -51,7 +66,7 @@ class _FakeSession implements ClientSession { class _RestTestApi implements ServerApiHandler { final expiry = Expiry(Duration(minutes: 5)); - final sessions = {}; + final sessions = {}; @override Future extendSession(SessionID sid) async { @@ -147,6 +162,26 @@ void main() { expect(canceled, true); }); + test('streams events sent through server state fanout', () async { + final state = ServerState(); + final creatorSid = _sid(1); + final receiverSid = _sid(2); + + state.clientSessions[creatorSid] = _FakeSession(sid: creatorSid); + final receiverSession = _FakeSession(sid: receiverSid); + state.clientSessions[receiverSid] = receiverSession; + api.sessions[receiverSid] = receiverSession; + + final response = await handler(_get(restSseSessionPath(receiverSid))); + expect(response.statusCode, 200); + + final event = KeepaliveEvent(); + state.sendEventToOthers(event, creatorSid); + + final chunk = await response.read().first.timeout(Duration(seconds: 2)); + expect(utf8.decode(chunk), 'event: keepalive\ndata: \n\n'); + }); + test('returns a clean error for an unknown SSE session', () async { final response = await handler(_get(restSseSessionPath(_sid(2)))); From 2ecf987143f0b72f887f802e1a6a2ebb63003ec2 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Wed, 17 Jun 2026 14:31:38 +0200 Subject: [PATCH 14/25] Allow disabling REST CORS headers --- README.md | 9 +++++++++ bin/grpc_server.dart | 10 +++++++++- lib/src/rest.dart | 13 +++++++++---- test/rest_test.dart | 12 ++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 12e6534..bac671e 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,11 @@ For production, replace `'*'` with the frontend origin that loads the web app: --rest-allow-origin https://app.example.com ``` +Only one layer should emit CORS headers. If a reverse proxy such as Caddy is +already adding `Access-Control-Allow-Origin`, run the backend with +`--rest-disable-cors` instead. Otherwise browsers will reject responses with a +combined value such as `*, *`. + ### Caddy Reverse Proxy Bind container ports to localhost when Caddy runs on the same host: @@ -144,6 +149,10 @@ api.example.com { } ``` +Do not add CORS headers in both Caddy and the backend. Either let the backend +handle CORS with `--rest-allow-origin`, or let Caddy handle it and run the +backend with `--rest-disable-cors`. + If the browser frontend is served from the same hostname and REST is under a prefix, strip the prefix before proxying: diff --git a/bin/grpc_server.dart b/bin/grpc_server.dart index 0078a88..71a2949 100644 --- a/bin/grpc_server.dart +++ b/bin/grpc_server.dart @@ -36,6 +36,12 @@ void main(List args) async { help: "CORS Access-Control-Allow-Origin value for REST/SSE clients", defaultsTo: "*", ); + argParser.addFlag( + "rest-disable-cors", + help: "Do not emit CORS headers; use when a reverse proxy handles CORS", + defaultsTo: false, + negatable: false, + ); argParser.addOption( "log-level", help: "Minimum log level to emit", @@ -69,7 +75,9 @@ void main(List args) async { if (restPort != null) { final restService = RestSseNoosphereService( api: apiHandler, - allowOrigin: argResults.option("rest-allow-origin")!, + allowOrigin: argResults.flag("rest-disable-cors") + ? null + : argResults.option("rest-allow-origin")!, ); final restAddress = argResults.option("rest-address")!; restServer = await restService.serve(address: restAddress, port: restPort); diff --git a/lib/src/rest.dart b/lib/src/rest.dart index cbb052d..c1001d9 100644 --- a/lib/src/rest.dart +++ b/lib/src/rest.dart @@ -49,7 +49,7 @@ Middleware restSseCors({ class RestSseNoosphereService { final ServerApiHandler api; - final String allowOrigin; + final String? allowOrigin; RestSseNoosphereService({ required this.api, @@ -74,9 +74,14 @@ class RestSseNoosphereService { ..post('/key-constructed/ack', _ackKeyConstructed) ..get('/sessions//events', _fetchEventStream); - return const Pipeline() - .addMiddleware(restSseCors(allowOrigin: allowOrigin)) - .addHandler(router.call); + var pipeline = const Pipeline(); + final allowOrigin = this.allowOrigin; + if (allowOrigin != null) { + pipeline = pipeline.addMiddleware( + restSseCors(allowOrigin: allowOrigin), + ); + } + return pipeline.addHandler(router.call); } Future serve({ diff --git a/test/rest_test.dart b/test/rest_test.dart index 94ce340..c4fcba5 100644 --- a/test/rest_test.dart +++ b/test/rest_test.dart @@ -112,6 +112,18 @@ void main() { ); }); + test('can leave CORS headers to a reverse proxy', () async { + final noCorsHandler = RestSseNoosphereService( + api: api, + allowOrigin: null, + ).handler; + + final response = await _post(noCorsHandler, '/extend-session', {}); + + expect(response.statusCode, 400); + expect(response.headers, isNot(contains('access-control-allow-origin'))); + }); + test('maps invalid requests to JSON errors with CORS headers', () async { final response = await _post(handler, '/extend-session', {}); From f2d4c59f092fada9f25e3b044f9e194092f137cf Mon Sep 17 00:00:00 2001 From: peerchemist Date: Thu, 18 Jun 2026 09:49:47 +0200 Subject: [PATCH 15/25] Replace SSE event stream with websockets --- README.md | 28 ++--- REST_API_SPEC.md | 35 +++---- bin/grpc_server.dart | 10 +- lib/src/rest.dart | 94 +++++++++++++---- lib/src/server/synchronized_api_handler.dart | 2 +- pubspec.yaml | 2 + test/rest_test.dart | 105 ++++++++++++++----- 7 files changed, 190 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index bac671e..308a5c6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ dart run noosphere_roast_server:grpc_server \ --log-level debug ``` -REST/SSE can be enabled for browser clients with `--rest-port`: +REST/WebSocket can be enabled for browser clients with `--rest-port`: ```sh dart run noosphere_roast_server:grpc_server \ @@ -28,13 +28,13 @@ dart run noosphere_roast_server:grpc_server \ `--rest-allow-origin '*'` is convenient for local testing, but production deployments should set `--rest-allow-origin` to the exact frontend origin that -will access the REST/SSE API, for example `https://app.example.com`. +will access the REST/WebSocket API, for example `https://app.example.com`. -Use `--rest-address 0.0.0.0` when the REST/SSE listener must be reachable from +Use `--rest-address 0.0.0.0` when the REST/WebSocket listener must be reachable from outside the process namespace, such as from a container port mapping. The -default REST/SSE bind address is `localhost`. +default REST/WebSocket bind address is `localhost`. -The REST/SSE API shape is documented in [REST_API_SPEC.md](REST_API_SPEC.md). +The REST/WebSocket API shape is documented in [REST_API_SPEC.md](REST_API_SPEC.md). ## Podman / Docker @@ -70,8 +70,8 @@ podman run --rm \ noosphere-roast-server ``` -The container starts both gRPC and REST/SSE by default. gRPC listens on the port -from the YAML config, or `50051` when `port` is omitted. REST/SSE listens on +The container starts both gRPC and REST/WebSocket by default. gRPC listens on the port +from the YAML config, or `50051` when `port` is omitted. REST/WebSocket listens on container port `8080`. Port mapping syntax is `host_port:container_port`. If the YAML config says @@ -94,7 +94,7 @@ podman run --rm \ /app/config.yaml --rest-address 0.0.0.0 --rest-port 8080 ``` -### REST/SSE With CORS +### REST/WebSocket With CORS For local testing, allow any browser origin and enable debug logs: @@ -139,7 +139,7 @@ podman run --rm \ --log-level info ``` -REST/SSE on a dedicated API hostname: +REST/WebSocket on a dedicated API hostname: ```caddyfile api.example.com { @@ -169,19 +169,19 @@ app.example.com { } ``` -### Ngrok For REST/SSE Testing +### Ngrok For REST/WebSocket Testing -Expose the REST/SSE port, not the gRPC port: +Expose the REST/WebSocket port, not the gRPC port: ```sh ngrok http 8080 ``` -Use the printed HTTPS URL as the REST base URL in the frontend. The SSE stream -will be under: +Use the printed HTTPS URL as the REST base URL in the frontend. The websocket +event stream will be under: ```text -https:///sessions//events +wss:///sessions//events ``` ### Logging diff --git a/REST_API_SPEC.md b/REST_API_SPEC.md index c14b1a0..b813286 100644 --- a/REST_API_SPEC.md +++ b/REST_API_SPEC.md @@ -1,6 +1,6 @@ -# Noosphere ROAST Server REST/SSE API +# Noosphere ROAST Server REST/WebSocket API -This specification is for implementing a frontend REST/SSE adapter for the +This specification is for implementing a frontend REST/WebSocket adapter for the Noosphere ROAST server. The same frontend may already support the gRPC endpoint; reuse the same Noosphere domain serializers and parsers where possible. @@ -83,7 +83,7 @@ Response: ``` Use the decoded `LoginCompleteResponse.id` as the session id for later calls -and SSE. +and the event websocket. ### POST /extend-session @@ -325,7 +325,7 @@ Response: {} ``` -## SSE Event Stream +## WebSocket Event Stream Open after login: @@ -336,22 +336,21 @@ GET /sessions//events `` is the raw `SessionID.n` bytes encoded as URL-safe base64 with padding removed. -Response headers include: +Open this endpoint as a websocket. Use `ws://` for plain HTTP deployments and +`wss://` when the REST server is served over HTTPS. -```text -content-type: text/event-stream -cache-control: no-cache -x-accel-buffering: no -``` - -Each SSE message: - -```text -event: -data: +Each websocket message is a JSON text frame: +```json +{ + "type": "dkg_commitment", + "data": "" +} ``` +`type` is the event name. `data` is the matching Noosphere `Event.toBytes()` +payload encoded as base64. + Event type mapping: ```text @@ -380,8 +379,8 @@ adapter. Most methods should be thin wrappers: 2. Base64 encode those bytes into the documented JSON fields. 3. `POST` the JSON request. 4. Decode returned `data` bytes back into the same domain response classes. -5. For SSE, route by the `event` name and parse `data` as the matching Event - bytes. +5. For websocket events, route by the JSON `type` value and parse `data` as the + matching Event bytes. The REST transport does not replace client-side protocol logic. DKG, signature-round, authentication, and key-sharing behavior should remain the diff --git a/bin/grpc_server.dart b/bin/grpc_server.dart index 71a2949..8b7bcfd 100644 --- a/bin/grpc_server.dart +++ b/bin/grpc_server.dart @@ -24,16 +24,16 @@ void main(List args) async { ); argParser.addOption( "rest-port", - help: "Optional REST/SSE port for browser clients", + help: "Optional REST/WebSocket port for browser clients", ); argParser.addOption( "rest-address", - help: "REST/SSE bind address", + help: "REST/WebSocket bind address", defaultsTo: "localhost", ); argParser.addOption( "rest-allow-origin", - help: "CORS Access-Control-Allow-Origin value for REST/SSE clients", + help: "CORS Access-Control-Allow-Origin value for REST/WebSocket clients", defaultsTo: "*", ); argParser.addFlag( @@ -73,7 +73,7 @@ void main(List args) async { HttpServer? restServer; if (restPort != null) { - final restService = RestSseNoosphereService( + final restService = RestWebSocketNoosphereService( api: apiHandler, allowOrigin: argResults.flag("rest-disable-cors") ? null @@ -82,7 +82,7 @@ void main(List args) async { final restAddress = argResults.option("rest-address")!; restServer = await restService.serve(address: restAddress, port: restPort); noosphereRoastServerLogger.i( - "REST/SSE server listening on $restAddress:${restServer.port}", + "REST/WebSocket server listening on $restAddress:${restServer.port}", ); } diff --git a/lib/src/rest.dart b/lib/src/rest.dart index c1001d9..ecc80b8 100644 --- a/lib/src/rest.dart +++ b/lib/src/rest.dart @@ -9,6 +9,8 @@ import 'package:noosphere_roast_server/src/server/api_handler.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf_router/shelf_router.dart'; +import 'package:shelf_web_socket/shelf_web_socket.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; Uint8List _bytes(List li) => Uint8List.fromList(li); SessionID _sid(List li) => SessionID.fromBytes(_bytes(li)); @@ -34,7 +36,7 @@ Map _corsHeaders(String allowOrigin) => { 'access-control-max-age': '86400', }; -Middleware restSseCors({ +Middleware restWebSocketCors({ String allowOrigin = '*', }) => (innerHandler) => (request) async { @@ -47,11 +49,15 @@ Middleware restSseCors({ return response.change(headers: {...response.headers, ...headers}); }; -class RestSseNoosphereService { +@Deprecated('Use restWebSocketCors.') +Middleware restSseCors({String allowOrigin = '*'}) => + restWebSocketCors(allowOrigin: allowOrigin); + +class RestWebSocketNoosphereService { final ServerApiHandler api; final String? allowOrigin; - RestSseNoosphereService({ + RestWebSocketNoosphereService({ required this.api, this.allowOrigin = '*', }); @@ -72,13 +78,13 @@ class RestSseNoosphereService { ..post('/signatures/replies', _submitSignatureReplies) ..post('/secret-share', _shareSecretShare) ..post('/key-constructed/ack', _ackKeyConstructed) - ..get('/sessions//events', _fetchEventStream); + ..get('/sessions//events', _fetchEventWebSocket); var pipeline = const Pipeline(); final allowOrigin = this.allowOrigin; if (allowOrigin != null) { pipeline = pipeline.addMiddleware( - restSseCors(allowOrigin: allowOrigin), + restWebSocketCors(allowOrigin: allowOrigin), ); } return pipeline.addHandler(router.call); @@ -282,20 +288,49 @@ class RestSseNoosphereService { ); }); - Future _fetchEventStream(Request request, String sid) async { + FutureOr _fetchEventWebSocket(Request request, String sid) { final description = _requestDescription(request); noosphereRoastServerLogger.d("REST $description received"); try { final session = api.getSession(_sid(_decodeBytes(sid))); - noosphereRoastServerLogger.d("REST $description opened"); - return Response.ok( - session.eventController.stream.map(_sseEvent), - headers: { - 'content-type': 'text/event-stream', - 'cache-control': 'no-cache', - 'x-accel-buffering': 'no', + final handler = webSocketHandler( + (WebSocketChannel webSocket, String? _) { + noosphereRoastServerLogger.d("REST $description opened"); + final eventSubscription = session.eventController.stream.listen( + (event) => webSocket.sink.add(_webSocketEvent(event)), + onDone: () { + unawaited(webSocket.sink.close(WebSocketStatus.normalClosure)); + }, + onError: (Object e, StackTrace stackTrace) { + noosphereRoastServerLogger.e( + "REST $description event stream failed", + error: e, + stackTrace: stackTrace, + ); + unawaited( + webSocket.sink.close(WebSocketStatus.internalServerError), + ); + }, + cancelOnError: true, + ); + webSocket.stream.listen( + (_) {}, + onDone: () { + unawaited(eventSubscription.cancel()); + noosphereRoastServerLogger.d("REST $description closed"); + }, + onError: (Object e) { + noosphereRoastServerLogger.w( + "REST $description socket failed: $e", + ); + unawaited(eventSubscription.cancel()); + }, + cancelOnError: true, + ); }, + allowedOrigins: _webSocketAllowedOrigins, ); + return handler(request); } on InvalidRequest catch (e) { noosphereRoastServerLogger.w( "REST $description rejected: ${e.message}", @@ -306,6 +341,8 @@ class RestSseNoosphereService { "REST $description rejected: ${e.message}", ); return _jsonResponse({'error': e.message}, status: 400); + } on HijackException { + rethrow; } on Exception catch (e, stackTrace) { noosphereRoastServerLogger.e( "REST $description failed", @@ -315,6 +352,20 @@ class RestSseNoosphereService { return _jsonResponse({'error': 'Internal server error'}, status: 500); } } + + Iterable? get _webSocketAllowedOrigins { + final allowOrigin = this.allowOrigin; + if (allowOrigin == null || allowOrigin == '*') return null; + return [allowOrigin]; + } +} + +@Deprecated('Use RestWebSocketNoosphereService.') +class RestSseNoosphereService extends RestWebSocketNoosphereService { + RestSseNoosphereService({ + required super.api, + super.allowOrigin, + }); } Future _handleEmpty( @@ -447,13 +498,13 @@ List _fieldStringList(Map json, String name) { }).toList(); } -List _sseEvent(Event event) { +String _webSocketEvent(Event event) { final type = _eventType(event); - noosphereRoastServerLogger.d("REST SSE sent $type"); - return utf8.encode( - 'event: $type\n' - 'data: ${_encodeBytes(event.toBytes())}\n\n', - ); + noosphereRoastServerLogger.d("REST WebSocket sent $type"); + return jsonEncode({ + 'type': type, + 'data': _encodeBytes(event.toBytes()), + }); } String _eventType(Event event) => switch (event) { @@ -473,5 +524,8 @@ String _eventType(Event event) => switch (event) { KeepaliveEvent() => 'keepalive', }; -String restSseSessionPath(SessionID sid) => +String restWebSocketSessionPath(SessionID sid) => '/sessions/${_encodeUrlBytes(sid.n)}/events'; + +@Deprecated('Use restWebSocketSessionPath.') +String restSseSessionPath(SessionID sid) => restWebSocketSessionPath(sid); diff --git a/lib/src/server/synchronized_api_handler.dart b/lib/src/server/synchronized_api_handler.dart index fdce306..a96ebbb 100644 --- a/lib/src/server/synchronized_api_handler.dart +++ b/lib/src/server/synchronized_api_handler.dart @@ -26,7 +26,7 @@ class _ApiCallQueue { /// A [ServerApiHandler] that serializes state-mutating API calls. /// /// Use one shared instance of this class when exposing the same coordinator -/// through multiple transports, such as gRPC for desktop clients and REST/SSE +/// through multiple transports, such as gRPC for desktop clients and REST/WebSocket /// for web clients. class SynchronizedServerApiHandler extends ServerApiHandler { final _queue = _ApiCallQueue(); diff --git a/pubspec.yaml b/pubspec.yaml index 3ed7c6f..8ae2139 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,8 @@ dependencies: args: ^2.6.0 shelf: ^1.4.2 shelf_router: ^1.1.4 + shelf_web_socket: ^2.0.1 + web_socket_channel: '>=2.0.0 <4.0.0' logger: ^2.7.0 dev_dependencies: diff --git a/test/rest_test.dart b/test/rest_test.dart index c4fcba5..87218ef 100644 --- a/test/rest_test.dart +++ b/test/rest_test.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'dart:typed_data'; import 'package:noosphere_roast_server/noosphere_roast_server.dart'; import 'package:noosphere_roast_server/src/server/state/client_session.dart'; import 'package:noosphere_roast_server/src/server/state/state.dart'; import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:test/test.dart'; String _b64(List bytes) => base64Encode(bytes); @@ -34,6 +36,12 @@ Future _post( ) async => await handler(_jsonPost(path, body)); +Future _serve(Handler handler) => + shelf_io.serve(handler, 'localhost', 0); + +String _wsUrl(HttpServer server, String path) => + 'ws://localhost:${server.port}$path'; + SessionID _sid([int lastByte = 1]) => SessionID.fromBytes(Uint8List(16)..last = lastByte); @@ -86,13 +94,13 @@ class _RestTestApi implements ServerApiHandler { } void main() { - group('RestSseNoosphereService', () { + group('RestWebSocketNoosphereService', () { late _RestTestApi api; late Handler handler; setUp(() { api = _RestTestApi(); - handler = RestSseNoosphereService( + handler = RestWebSocketNoosphereService( api: api, allowOrigin: 'https://app.example', ).handler; @@ -113,7 +121,7 @@ void main() { }); test('can leave CORS headers to a reverse proxy', () async { - final noCorsHandler = RestSseNoosphereService( + final noCorsHandler = RestWebSocketNoosphereService( api: api, allowOrigin: null, ).handler; @@ -155,26 +163,38 @@ void main() { ); }); - test('streams SSE events and cancels the session stream', () async { - var canceled = false; + test('streams websocket events and cancels the session stream', () async { + final canceled = Completer(); final sid = _sid(); - final session = _FakeSession(onCancel: () => canceled = true); + final session = _FakeSession( + onCancel: () { + if (!canceled.isCompleted) canceled.complete(); + }, + ); api.sessions[sid] = session; - final response = await handler(_get(restSseSessionPath(sid))); - expect(response.statusCode, 200); - expect(response.headers['content-type'], 'text/event-stream'); - expect(response.headers['cache-control'], 'no-cache'); - expect(response.headers['x-accel-buffering'], 'no'); - - session.send(KeepaliveEvent()); - - final chunk = await response.read().first.timeout(Duration(seconds: 2)); - expect(utf8.decode(chunk), 'event: keepalive\ndata: \n\n'); - expect(canceled, true); + final server = await _serve(handler); + try { + final socket = await WebSocket.connect( + _wsUrl(server, restWebSocketSessionPath(sid)), + ); + + session.send(KeepaliveEvent()); + + final message = await socket.first.timeout(Duration(seconds: 2)); + expect(jsonDecode(message as String), { + 'type': 'keepalive', + 'data': '', + }); + + await socket.close(); + await canceled.future.timeout(Duration(seconds: 2)); + } finally { + await server.close(force: true); + } }); - test('streams events sent through server state fanout', () async { + test('streams websocket events sent through server state fanout', () async { final state = ServerState(); final creatorSid = _sid(1); final receiverSid = _sid(2); @@ -184,22 +204,51 @@ void main() { state.clientSessions[receiverSid] = receiverSession; api.sessions[receiverSid] = receiverSession; - final response = await handler(_get(restSseSessionPath(receiverSid))); - expect(response.statusCode, 200); - - final event = KeepaliveEvent(); - state.sendEventToOthers(event, creatorSid); - - final chunk = await response.read().first.timeout(Duration(seconds: 2)); - expect(utf8.decode(chunk), 'event: keepalive\ndata: \n\n'); + final server = await _serve(handler); + try { + final socket = await WebSocket.connect( + _wsUrl(server, restWebSocketSessionPath(receiverSid)), + ); + + final event = KeepaliveEvent(); + state.sendEventToOthers(event, creatorSid); + + final message = await socket.first.timeout(Duration(seconds: 2)); + expect(jsonDecode(message as String), { + 'type': 'keepalive', + 'data': '', + }); + + await socket.close(); + } finally { + await server.close(force: true); + } }); - test('returns a clean error for an unknown SSE session', () async { - final response = await handler(_get(restSseSessionPath(_sid(2)))); + test('returns a clean error for an unknown websocket session', () async { + final response = await handler(_get(restWebSocketSessionPath(_sid(2)))); expect(response.statusCode, 400); final body = jsonDecode(await response.readAsString()); expect(body, {'error': InvalidRequest.noSession().message}); }); + + test('rejects websocket connections from a different origin', () async { + final sid = _sid(); + api.sessions[sid] = _FakeSession(); + + final server = await _serve(handler); + try { + await expectLater( + WebSocket.connect( + _wsUrl(server, restWebSocketSessionPath(sid)), + headers: {'Origin': 'https://other.example'}, + ), + throwsA(isA()), + ); + } finally { + await server.close(force: true); + } + }); }); } From 914039e407673d477eda421d2c14a95321474ef8 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Thu, 18 Jun 2026 14:58:31 +0200 Subject: [PATCH 16/25] ran formatting --- example/taproot_example.dart | 34 +- lib/noosphere_roast_server.dart | 1 + lib/src/config/server.dart | 98 +-- lib/src/server/state/client_session.dart | 4 - lib/src/server/state/dkg.dart | 4 +- lib/src/server/state/key_sharing.dart | 39 +- lib/src/server/state/ring_buffer.dart | 4 +- .../server/state/signatures_coordination.dart | 29 +- test/client_test.dart | 675 ++++++++---------- test/context.dart | 84 +-- test/data.dart | 40 +- test/frost_key_with_details_test.dart | 86 ++- test/grpc_test.dart | 91 ++- test/helpers.dart | 11 +- test/server/api_handler_test.dart | 524 ++++++-------- test/server/ring_buffer_test.dart | 9 +- test/sig_data.dart | 59 +- test/test_keys.dart | 45 +- 18 files changed, 827 insertions(+), 1010 deletions(-) diff --git a/example/taproot_example.dart b/example/taproot_example.dart index 7382b74..23d6ac7 100644 --- a/example/taproot_example.dart +++ b/example/taproot_example.dart @@ -16,7 +16,6 @@ int getCommandLineInt(String prompt, int min, int max) { } class EventCompleters { - final gotDkg = Completer(); final gotSigsReq = Completer(); final signature = Completer(); @@ -31,7 +30,8 @@ class EventCompleters { gotSigsReq.complete(); case SignaturesCompleteClientEvent(): signature.complete(event.signatures.first); - case _: break; + case _: + break; } }, onError: (Object err) { @@ -40,7 +40,6 @@ class EventCompleters { }, ); } - } const maxParticipants = 1000; @@ -48,19 +47,21 @@ const port = 13543; const String keyName = "example_key"; void main() async { - await loadFrosty(); final nParticipants = getCommandLineInt( - "Number of participants", 2, maxParticipants, + "Number of participants", + 2, + maxParticipants, ); final threshold = getCommandLineInt("Threshold", 2, nParticipants); print("Creating server"); - final ids = List.generate(nParticipants, (i) => Identifier.fromUint16(i+1)); + final ids = List.generate(nParticipants, (i) => Identifier.fromUint16(i + 1)); final participantKeys = List.generate( - nParticipants, (i) => cl.ECPrivateKey.generate(), + nParticipants, + (i) => cl.ECPrivateKey.generate(), ); final groupConfig = GroupConfig( @@ -105,9 +106,11 @@ void main() async { ), ), ); - final clientCompleters = clients.map( - (client) => EventCompleters(client.events), - ).toList(); + final clientCompleters = clients + .map( + (client) => EventCompleters(client.events), + ) + .toList(); print("Creating $threshold-of-$nParticipants key"); @@ -161,10 +164,12 @@ void main() async { final taproot = cl.Taproot(internalKey: derivedPubkey); final testnetAddr = cl.P2TRAddress.fromTaproot( - taproot, hrp: cl.Network.testnet.bech32Hrp, + taproot, + hrp: cl.Network.testnet.bech32Hrp, ); final mainnetAddr = cl.P2TRAddress.fromTaproot( - taproot, hrp: cl.Network.mainnet.bech32Hrp, + taproot, + hrp: cl.Network.mainnet.bech32Hrp, ); print("Testnet Taproot address: $testnetAddr"); print("Mainnet Taproot address: $mainnetAddr"); @@ -231,8 +236,8 @@ void main() async { ); await Future.wait( clients.skip(1).map( - (client) => client.acceptSignaturesRequest(requestDetails.id), - ), + (client) => client.acceptSignaturesRequest(requestDetails.id), + ), ); // Wait for signature @@ -255,5 +260,4 @@ void main() async { // Recommended to use exit() to protect against gRPC hanging exit(0); - } diff --git a/lib/noosphere_roast_server.dart b/lib/noosphere_roast_server.dart index b7c9a61..068f922 100644 --- a/lib/noosphere_roast_server.dart +++ b/lib/noosphere_roast_server.dart @@ -1,2 +1,3 @@ library; + export 'src/noosphere_roast_server_base.dart'; diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart index 9d38796..5e7ecb8 100644 --- a/lib/src/config/server.dart +++ b/lib/src/config/server.dart @@ -4,7 +4,6 @@ import 'package:noosphere_roast_client/common.dart'; import 'package:noosphere_roast_client/noosphere_roast_client.dart'; class ServerConfig with cl.Writable, MapWritable { - static const defaultChallengeTTL = Duration(seconds: 20); static const defaultSessionTTL = Duration(minutes: 1); static const defaultMinDkgRequestTTL = Duration(minutes: 29); @@ -42,48 +41,50 @@ class ServerConfig with cl.Writable, MapWritable { /// Convenience constructor to construct from serialised [bytes]. ServerConfig.fromBytes(Uint8List bytes) - : this.fromReader(cl.BytesReader(bytes)); + : this.fromReader(cl.BytesReader(bytes)); /// Convenience constructor to construct from encoded [hex]. ServerConfig.fromHex(String hex) : this.fromBytes(cl.hexToBytes(hex)); - ServerConfig.fromReader(cl.BytesReader reader) : this( - group: GroupConfig.fromReader(reader), - challengeTTL: reader.readDuration(), - sessionTTL: reader.readDuration(), - minDkgRequestTTL: reader.readDuration(), - maxDkgRequestTTL: reader.readDuration(), - minSignaturesRequestTTL: reader.readDuration(), - maxSignaturesRequestTTL: reader.readDuration(), - minCompletedSignaturesTTL: reader.readDuration(), - ackCacheTTL: reader.readDuration(), - keepAliveFreq: reader.readBool() ? reader.readDuration() : null, - ); - - ServerConfig.fromMapReader(MapReader reader) : this( - group: GroupConfig.fromMapReader(reader["group"]), - challengeTTL: reader.getTTL("challenge") ?? defaultChallengeTTL, - sessionTTL: reader.getTTL("session") ?? defaultSessionTTL, - minDkgRequestTTL: reader.getTTL("min-dkg-request") - ?? defaultMinDkgRequestTTL, - maxDkgRequestTTL: reader.getTTL("max-dkg-request") - ?? defaultMaxDkgRequestTTL, - minSignaturesRequestTTL: reader.getTTL("min-signatures-request") - ?? defaultMinSignaturesRequestTTL, - maxSignaturesRequestTTL: reader.getTTL("max-signatures-request") - ?? defaultMaxSignaturesRequestTTL, - minCompletedSignaturesTTL: reader.getTTL("min-completed-signatures") - ?? defaultMinCompletedSignaturesTTL, - ackCacheTTL: reader.getTTL("ack-cache") ?? defaultAckCacheTTL, - keepAliveFreq: reader["keep-alive-event-ms"].duration(), - ); + ServerConfig.fromReader(cl.BytesReader reader) + : this( + group: GroupConfig.fromReader(reader), + challengeTTL: reader.readDuration(), + sessionTTL: reader.readDuration(), + minDkgRequestTTL: reader.readDuration(), + maxDkgRequestTTL: reader.readDuration(), + minSignaturesRequestTTL: reader.readDuration(), + maxSignaturesRequestTTL: reader.readDuration(), + minCompletedSignaturesTTL: reader.readDuration(), + ackCacheTTL: reader.readDuration(), + keepAliveFreq: reader.readBool() ? reader.readDuration() : null, + ); + + ServerConfig.fromMapReader(MapReader reader) + : this( + group: GroupConfig.fromMapReader(reader["group"]), + challengeTTL: reader.getTTL("challenge") ?? defaultChallengeTTL, + sessionTTL: reader.getTTL("session") ?? defaultSessionTTL, + minDkgRequestTTL: + reader.getTTL("min-dkg-request") ?? defaultMinDkgRequestTTL, + maxDkgRequestTTL: + reader.getTTL("max-dkg-request") ?? defaultMaxDkgRequestTTL, + minSignaturesRequestTTL: reader.getTTL("min-signatures-request") ?? + defaultMinSignaturesRequestTTL, + maxSignaturesRequestTTL: reader.getTTL("max-signatures-request") ?? + defaultMaxSignaturesRequestTTL, + minCompletedSignaturesTTL: + reader.getTTL("min-completed-signatures") ?? + defaultMinCompletedSignaturesTTL, + ackCacheTTL: reader.getTTL("ack-cache") ?? defaultAckCacheTTL, + keepAliveFreq: reader["keep-alive-event-ms"].duration(), + ); ServerConfig.fromYaml(String yaml) - : this.fromMapReader(MapReader.fromYaml(yaml)); + : this.fromMapReader(MapReader.fromYaml(yaml)); @override void write(cl.Writer writer) { - group.write(writer); writer.writeDuration(challengeTTL); @@ -100,23 +101,22 @@ class ServerConfig with cl.Writable, MapWritable { if (useKeepalive) { writer.writeDuration(keepAliveFreq!); } - } @override Map map() => { - "ms-lifetimes": { - "challenge": challengeTTL.inMilliseconds, - "session": sessionTTL.inMilliseconds, - "min-dkg-request": minDkgRequestTTL.inMilliseconds, - "max-dkg-request": maxDkgRequestTTL.inMilliseconds, - "min-signatures-request": minSignaturesRequestTTL.inMilliseconds, - "max-signatutres-request": maxSignaturesRequestTTL.inMilliseconds, - "min-completed-signatures": minCompletedSignaturesTTL.inMilliseconds, - "ack-cache": ackCacheTTL.inMilliseconds, - }, - if (keepAliveFreq != null) "keep-alive-event-ms": keepAliveFreq!.inMilliseconds, - "group": group.map(), - }; - + "ms-lifetimes": { + "challenge": challengeTTL.inMilliseconds, + "session": sessionTTL.inMilliseconds, + "min-dkg-request": minDkgRequestTTL.inMilliseconds, + "max-dkg-request": maxDkgRequestTTL.inMilliseconds, + "min-signatures-request": minSignaturesRequestTTL.inMilliseconds, + "max-signatutres-request": maxSignaturesRequestTTL.inMilliseconds, + "min-completed-signatures": minCompletedSignaturesTTL.inMilliseconds, + "ack-cache": ackCacheTTL.inMilliseconds, + }, + if (keepAliveFreq != null) + "keep-alive-event-ms": keepAliveFreq!.inMilliseconds, + "group": group.map(), + }; } diff --git a/lib/src/server/state/client_session.dart b/lib/src/server/state/client_session.dart index 4510ef7..d88378d 100644 --- a/lib/src/server/state/client_session.dart +++ b/lib/src/server/state/client_session.dart @@ -3,7 +3,6 @@ import 'package:noosphere_roast_client/noosphere_roast_client.dart'; import 'ring_buffer.dart'; class ClientSession implements Expirable { - final Identifier participantId; final SessionID sessionID; @override @@ -18,7 +17,6 @@ class ClientSession implements Expirable { required this.expiry, required void Function() onLostStream, }) { - void flushEvents() { for (final event in eventBuffer.flushBuffer()) { eventController.add(event); @@ -32,7 +30,6 @@ class ClientSession implements Expirable { // a connection to the event stream onCancel: onLostStream, ); - } void sendEvent(Event e) { @@ -45,5 +42,4 @@ class ClientSession implements Expirable { eventController.add(e); } } - } diff --git a/lib/src/server/state/dkg.dart b/lib/src/server/state/dkg.dart index 4c01878..a02551b 100644 --- a/lib/src/server/state/dkg.dart +++ b/lib/src/server/state/dkg.dart @@ -16,11 +16,10 @@ class DkgRound1State extends DkgRoundState { class DkgRound2State extends DkgRoundState { final Uint8List expectedHash; final List participantsProvided = []; - DkgRound2State({ required this.expectedHash }); + DkgRound2State({required this.expectedHash}); } class DkgState implements Expirable { - // Saved across the state so that details are available if round 1 needs to // be done again. final Signed details; @@ -40,5 +39,4 @@ class DkgState implements Expirable { DkgRound1State get round1 => round as DkgRound1State; DkgRound2State get round2 => round as DkgRound2State; - } diff --git a/lib/src/server/state/key_sharing.dart b/lib/src/server/state/key_sharing.dart index 3f98fcc..154f96a 100644 --- a/lib/src/server/state/key_sharing.dart +++ b/lib/src/server/state/key_sharing.dart @@ -3,12 +3,11 @@ import 'package:noosphere_roast_client/noosphere_roast_client.dart'; class KeyShareFromSender { final EncryptedKeyShare share; final Identifier sender; - KeyShareFromSender({ required this.share, required this.sender}); + KeyShareFromSender({required this.share, required this.sender}); } /// The encrypted key shares stored by the server for a given FROST key class KeySharingState { - /// The stored encrypted shares for a given recipient. final Map receiverShares = {}; @@ -19,30 +18,26 @@ class KeySharingState { Identifier receiver, EncryptedKeyShare share, ) { + final shareState = + receiverShares[receiver] ??= ParticipantPendingShareState(); - final shareState = receiverShares[receiver] ??= ParticipantPendingShareState(); - - if ( - shareState is ParticipantPendingShareState - && !shareState.haveForSender(sender) - ) { + if (shareState is ParticipantPendingShareState && + !shareState.haveForSender(sender)) { shareState.pendingForSender[sender] = share; return true; } return false; - } - List getSharesForReceiver(Identifier receiver) - => receiverShares[receiver]?.pendingShares ?? []; - - List eventsForCompleted(Iterable ids) - => ids.map((id) => receiverShares[id]) - .whereType() - .map((state) => state.constructedEvent) - .toList(); + List getSharesForReceiver(Identifier receiver) => + receiverShares[receiver]?.pendingShares ?? []; + List eventsForCompleted(Iterable ids) => ids + .map((id) => receiverShares[id]) + .whereType() + .map((state) => state.constructedEvent) + .toList(); } sealed class ParticipantShareState { @@ -50,17 +45,17 @@ sealed class ParticipantShareState { } class ParticipantPendingShareState extends ParticipantShareState { - /// The encrypted key shares that the server has for the participant final Map pendingForSender = {}; bool haveForSender(Identifier sender) => pendingForSender.containsKey(sender); @override - List get pendingShares => pendingForSender.entries.map( - (entry) => KeyShareFromSender(share: entry.value, sender: entry.key), - ).toList(); - + List get pendingShares => pendingForSender.entries + .map( + (entry) => KeyShareFromSender(share: entry.value, sender: entry.key), + ) + .toList(); } /// Used after the participant acknowledged the completion of the construction diff --git a/lib/src/server/state/ring_buffer.dart b/lib/src/server/state/ring_buffer.dart index 7a3cd5d..d9bd48f 100644 --- a/lib/src/server/state/ring_buffer.dart +++ b/lib/src/server/state/ring_buffer.dart @@ -1,6 +1,5 @@ /// Takes a certain number of [T] objects, retaining the last added objects class RingBuffer { - final List buffer = []; final int maxSize; int next = 0; @@ -15,7 +14,7 @@ class RingBuffer { } else { buffer[next] = element; } - next = (next+1) % maxSize; + next = (next + 1) % maxSize; } List flushBuffer() { @@ -24,5 +23,4 @@ class RingBuffer { next = 0; return ordered; } - } diff --git a/lib/src/server/state/signatures_coordination.dart b/lib/src/server/state/signatures_coordination.dart index bde2c4b..6689fa4 100644 --- a/lib/src/server/state/signatures_coordination.dart +++ b/lib/src/server/state/signatures_coordination.dart @@ -13,16 +13,16 @@ sealed class SingleSignatureState {} /// ROAST state for a signature that is not finished class SingleSignatureInProgressState extends SingleSignatureState { - /// The master key info required for this signature final AggregateKeyInfo key; + /// The collected commitments for the next round final SigningCommitmentMap nextCommitments = {}; + /// Maps the participant identifiers to the ROAST rounds. final Map roundForId = {}; SingleSignatureInProgressState(this.key); - } /// A completed signature @@ -34,13 +34,13 @@ class SingleSignatureFinishedState extends SingleSignatureState { /// Handles the state for ROAST signature coordination for a set of requested /// signatures. class SignaturesCoordinationState implements Expirable { - final Signed details; final Identifier creator; final List sigs; /// Participants that are determined to be malicious final Set malicious = {}; + /// Participants that reject a request will be stored here unless they /// withdraw the rejection. final Set rejectors = {}; @@ -49,28 +49,26 @@ class SignaturesCoordinationState implements Expirable { required this.details, required this.creator, required Set keys, - }) : sigs = details.obj.requiredSigs.map( - (reqSig) => SingleSignatureInProgressState( - keys.firstWhere((k) => k.groupKey == reqSig.groupKey), - ) as SingleSignatureState, - ).toList(); + }) : sigs = details.obj.requiredSigs + .map( + (reqSig) => SingleSignatureInProgressState( + keys.firstWhere((k) => k.groupKey == reqSig.groupKey), + ) as SingleSignatureState, + ) + .toList(); @override Expiry get expiry => details.obj.expiry; List pendingRoundsForId(Identifier id) { - final List rounds = []; for (int i = 0; i < sigs.length; i++) { - final sig = sigs[i]; // Id must be in a round - if ( - sig is! SingleSignatureInProgressState - || !sig.roundForId.containsKey(id) - ) { + if (sig is! SingleSignatureInProgressState || + !sig.roundForId.containsKey(id)) { continue; } @@ -80,11 +78,8 @@ class SignaturesCoordinationState implements Expirable { if (round.shares.any((share) => share.$1 == id)) continue; rounds.add(SignatureRoundStart(sigI: i, commitments: round.commitments)); - } return rounds; - } - } diff --git a/test/client_test.dart b/test/client_test.dart index 14603b8..7df5bda 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -16,7 +16,6 @@ import 'test_keys.dart'; void main() { group("Client", () { - late final Signed dkgDetails; late final List dummyPart1s; late final DkgCommitmentSet dummyCommitmentSet; @@ -39,8 +38,9 @@ void main() { setUp(() => ctx = TestContext()); Future expectMisbehaviour(void Function() f) => expectLater( - f, throwsA(isA()), - ); + f, + throwsA(isA()), + ); final List clientsToLogout = []; tearDown(() async { @@ -52,10 +52,9 @@ void main() { Future login( int i, { - InMemoryClientStorage? storage, - void Function()? onDisconnect, - } - ) async { + InMemoryClientStorage? storage, + void Function()? onDisconnect, + }) async { final client = await TestClient.login( ctx.api, i, @@ -67,8 +66,8 @@ void main() { } Future> loginMany(int n) => Future.wait( - List.generate(n, (i) => login(i)), - ); + List.generate(n, (i) => login(i)), + ); void sendEventToClient(TestClient tc, Event ev) { final clientState = ctx.api.state.clientSessions.values.firstWhere( @@ -89,28 +88,32 @@ void main() { } DkgRound2ShareEvent getRound2ShareEvent( - DkgCommitmentSet commitments, int sender, int receiver, - DkgPart1 part1, Uint8List commonHash, - { String? altName, } - ) => DkgRound2ShareEvent( - name: altName ?? "123", - commitmentSetSignature: cl.SchnorrSignature.sign( - getPrivkey(sender), commonHash, - ), - sender: ids[sender], - secret: DkgEncryptedSecret.encrypt( - secretShare: DkgPart2( - identifier: ids[sender], - round1Secret: part1.secret, - commitments: commitments, - ).sharesToGive[ids[receiver]]!, - recipientKey: getPrivkey(receiver).pubkey, - senderKey: getPrivkey(sender), - ), - ); + DkgCommitmentSet commitments, + int sender, + int receiver, + DkgPart1 part1, + Uint8List commonHash, { + String? altName, + }) => + DkgRound2ShareEvent( + name: altName ?? "123", + commitmentSetSignature: cl.SchnorrSignature.sign( + getPrivkey(sender), + commonHash, + ), + sender: ids[sender], + secret: DkgEncryptedSecret.encrypt( + secretShare: DkgPart2( + identifier: ids[sender], + round1Secret: part1.secret, + commitments: commitments, + ).sharesToGive[ids[receiver]]!, + recipientKey: getPrivkey(receiver).pubkey, + senderKey: getPrivkey(sender), + ), + ); test("can login and keep track of online participants", () async { - // Login 2 clients before creating Client object await ctx.multiLogin(2); @@ -130,6 +133,7 @@ void main() { expect(dkgRequest.completed, commitmentIds); expect(client.acceptedDkgs, isEmpty); } + expectRound1Dkg({ids[0], ids[1]}); // Login two more clients and expect an event @@ -145,9 +149,11 @@ void main() { expect(client.onlineParticipants, {ids[0], ids[1], ids[3], ids[4]}); // Logout client - ctx.api.state.clientSessions.values.firstWhere( - (v) => v.participantId == ids[0], - ).expiry = Expiry(Duration(minutes: -1)); + ctx.api.state.clientSessions.values + .firstWhere( + (v) => v.participantId == ids[0], + ) + .expiry = Expiry(Duration(minutes: -1)); ctx.api.state.clientSessions.values; // Will expire { @@ -162,7 +168,6 @@ void main() { // Round 1 DKG loses commitment from logged out participant expectRound1Dkg({ids[1]}); - }); test("logout old client object if re-login", () async { @@ -173,7 +178,6 @@ void main() { }); test("handles multiple logouts immediately", () async { - final tcs = await Future.wait(List.generate(6, (i) => login(i))); await tcs.first.expectOnlyLoginEvents(); @@ -183,19 +187,17 @@ void main() { final evs = await tcs.first.evCollector.getEvents(); expect(evs, hasLength(5)); expect(evs, everyElement(isA())); - }); group("handles login misbehaviour", () { - void expectLoginMisbehaviour() => expectMisbehaviour( - () => Client.login( - config: getClientConfig(0), - api: ctx.api, - store: InMemoryClientStorage(), - getPrivateKey: (_) async => getPrivkey(0), - ), - ); + () => Client.login( + config: getClientConfig(0), + api: ctx.api, + store: InMemoryClientStorage(), + getPrivateKey: (_) async => getPrivkey(0), + ), + ); test("online participant not in config", () { final mockSessionId = SessionID(); @@ -241,13 +243,12 @@ void main() { }); test("duplicate DKG", () { - ctx.api.state.nameToDkg["one"] - = ctx.api.state.nameToDkg["two"] - = DkgState( - details: dkgDetails, - creator: ids.first, - commitments: [], - ); + ctx.api.state.nameToDkg["one"] = + ctx.api.state.nameToDkg["two"] = DkgState( + details: dkgDetails, + creator: ids.first, + commitments: [], + ); expectLoginMisbehaviour(); }); @@ -256,16 +257,14 @@ void main() { final sigsDetails2 = getSignaturesDetails( singleSigTweaks: [1], ); - ctx.api.state.sigRequests[sigsDetails1.id] - = ctx.api.state.sigRequests[sigsDetails2.id] - = SignaturesCoordinationState( - details: signObject(sigsDetails1), - creator: ids.first, - keys: {getAggregateKeyInfo()}, - ); + ctx.api.state.sigRequests[sigsDetails1.id] = ctx.api.state + .sigRequests[sigsDetails2.id] = SignaturesCoordinationState( + details: signObject(sigsDetails1), + creator: ids.first, + keys: {getAggregateKeyInfo()}, + ); expectLoginMisbehaviour(); }); - }); test("does not receive events after logout", () async { @@ -277,7 +276,6 @@ void main() { }); test("handles incorrect participant login event", () async { - await expectBadEvent( await login(0), ParticipantStatusEvent(id: badId, loggedIn: true), @@ -288,7 +286,6 @@ void main() { await login(0), ParticipantStatusEvent(id: ids.first, loggedIn: true), ); - }); test("handles error made on server stream and disconnects", () async { @@ -302,7 +299,6 @@ void main() { }); test("requestDkg success", () async { - var tc1 = await login(0); var tc2 = await login(1); await tc1.expectOnlyLoginEvents(); @@ -313,12 +309,12 @@ void main() { expect(tc1.client.dkgExists("123"), true); void expectProgress( - DkgInProgress progress, - [ Set? completed, ] - ) { + DkgInProgress progress, [ + Set? completed, + ]) { expect(progress.creator, ids.first); expect(progress.stage, DkgStage.round1); - expect(progress.completed, completed ?? { ids.first }); + expect(progress.completed, completed ?? {ids.first}); expect(progress.details.threshold, 10); } @@ -349,21 +345,18 @@ void main() { // Both clients lose acceptance expectDkg(tc1.client, false, false); expectDkg(tc2.client, false, false); - }); test("requestDkg failure", () async { - final TestClient(:client) = await login(0); final existingDetails = getDkgDetails(name: "exists"); await client.requestDkg(existingDetails); - Future expectFail(NewDkgDetails details) - => expectLater( - () => client.requestDkg(details), - throwsArgumentError, - ); + Future expectFail(NewDkgDetails details) => expectLater( + () => client.requestDkg(details), + throwsArgumentError, + ); for (final duration in [ Duration(minutes: 29, seconds: 59), @@ -374,11 +367,9 @@ void main() { await expectFail(existingDetails); await expectFail(getDkgDetails(threshold: 11)); - }); Future expectNoRaceCondition(Future Function() f) async { - final futures = List.generate(20, (_) => f()); int argumentErrors = 0; @@ -390,7 +381,6 @@ void main() { } } expect(argumentErrors, 19); - } test("requestDkg race condition", () async { @@ -401,7 +391,6 @@ void main() { }); test("handles incorrect DKG request event", () async { - Future expectBadDkg({ Signed? details, Identifier? creator, @@ -441,11 +430,9 @@ void main() { getDkgDetails(expiry: Expiry(Duration(minutes: -2, seconds: -1))), ), ); - }); test("DKG expiry is clamped", () async { - final TestClient(:client, :evCollector) = await login(1); final reqExp = Expiry(Duration(days: 8)); @@ -474,11 +461,9 @@ void main() { ev.progress.expiry.time, client.dkgRequests.first.expiry.time, ); - }); test("DKGs can be replaced with same name", () async { - final tc = await login(5); final ev1 = NewDkgEvent( @@ -497,13 +482,12 @@ void main() { await tc.evCollector.getExpectOneEvent(); ctx.api.state.sendEventToAll(ev2); - final ev = await tc.evCollector - .getExpectOneEvent(); + final ev = + await tc.evCollector.getExpectOneEvent(); expect(ev.progress.details.name, "123"); expect(ev.progress.creator, ids[1]); expect(tc.client.dkgRequests, hasLength(1)); expect(tc.client.dkgRequests.first.creator, ids[1]); - }); test("ignore DKG that doesn't exist", () async { @@ -513,11 +497,10 @@ void main() { }); test("DKGs can be rejected", () async { - - final TestClient(client: client1, evCollector: evCollector1) - = await login(0); - final TestClient(client: client2, evCollector: evCollector2) - = await login(1); + final TestClient(client: client1, evCollector: evCollector1) = + await login(0); + final TestClient(client: client2, evCollector: evCollector2) = + await login(1); // Client 1 add DKG await client1.requestDkg(getDkgDetails()); @@ -539,11 +522,9 @@ void main() { // No DKG for both clients expect(client1.dkgRequests, isEmpty); expect(client2.dkgRequests, isEmpty); - }); test("handles incorrect DKG rejection event", () async { - // Participant doesn't exist, or is self for (final badId in [badId, ids.first]) { await expectBadEvent( @@ -551,11 +532,9 @@ void main() { DkgRejectEvent(name: "123", participant: badId), ); } - }); test("Non-existant DKG ignored", () async { - final TestClient(:evCollector) = await login(0); ctx.api.state.sendEventToAll( @@ -572,18 +551,19 @@ void main() { ctx.api.state.sendEventToAll( getRound2ShareEvent( - dummyCommitmentSet, 1, 0, dummyPart1s[1], + dummyCommitmentSet, + 1, + 0, + dummyPart1s[1], Uint8List(32), altName: "noexist", ), ); await evCollector.expectNoEventsOrError(); - }); test("can create FROST keys", () async { - // Create 10 clients final tcs = await loginMany(10); for (final tc in tcs) { @@ -609,7 +589,6 @@ void main() { // Expect progress events and then completion event for (int i = 0; i < 10; i++) { - final cid = ids[i]; final evCollector = tcs[i].evCollector; await evCollector.expectNoError(); @@ -626,9 +605,8 @@ void main() { // Commitment events for (int j = 0; j < nCommitments; j++) { - final ev = evs[j] as UpdatedDkgClientEvent; - final finished = j == nCommitments-1; + final finished = j == nCommitments - 1; final completed = ev.progress.completed; // Apart from creator and first acceptor, participants may or may not @@ -647,12 +625,13 @@ void main() { // If round 2, then have just own share // If round 1, then have received commitments, plus creator, plus // own - round2 ? 1 : j+1+(isCreator ? 0 : 1)+(hasOwnCommitment ? 1 : 0), + round2 + ? 1 + : j + 1 + (isCreator ? 0 : 1) + (hasOwnCommitment ? 1 : 0), ), ); if (!round2) expect(completed, contains(ids.first)); - } // Expect 8 update events from shares @@ -660,13 +639,12 @@ void main() { // called for (int j = 0; j < 8; j++) { - final ev = evs[nCommitments+j] as UpdatedDkgClientEvent; + final ev = evs[nCommitments + j] as UpdatedDkgClientEvent; final completed = ev.progress.completed; expect(ev.progress.stage, DkgStage.round2); - expect(completed, hasLength(j+2)); + expect(completed, hasLength(j + 2)); expect(completed, contains(cid)); } - } // Expect key in storage @@ -682,11 +660,9 @@ void main() { for (final tc in tcs) { expectNoDkgs(tc.client); } - }); test("cannot accept DKG twice", () async { - final client1 = (await login(0)).client; final client2 = (await login(1)).client; @@ -696,11 +672,9 @@ void main() { for (final client in [client1, client2]) { expectLater(() => client.acceptDkg("123"), throwsArgumentError); } - }); test("handles incorrect DkgCommitmentEvent", () async { - final tc = await login(0); await tc.client.requestDkg(getDkgDetails()); @@ -730,7 +704,6 @@ void main() { await tc2.evCollector.getExpectOneEvent(); await tc2.evCollector.expectError(); - }); test("handles round 2 share given on round 1", () async { @@ -739,7 +712,11 @@ void main() { await expectBadEvent( tc, getRound2ShareEvent( - dummyCommitmentSet, 1, 0, dummyPart1s[1], Uint8List(32), + dummyCommitmentSet, + 1, + 0, + dummyPart1s[1], + Uint8List(32), ), ); }); @@ -756,7 +733,6 @@ void main() { }); test("handles invalid proof-of-knowledge", () async { - final TestClient(:client, :evCollector) = await login(0); await client.requestDkg(getDkgDetails()); @@ -781,11 +757,9 @@ void main() { expect(rejectEv.fault, DkgFault.proofOfKnowledge); await evCollector.expectNoError(); - }); test("remove DKG upon expiry", () async { - final tc = await login(0); final state = getHiddenClientStateForTestsDoNotUse(tc.client); @@ -796,25 +770,23 @@ void main() { ); // Should get failure event due to expiry - final ev = await tc.evCollector.getExpectOneEvent(); + final ev = + await tc.evCollector.getExpectOneEvent(); expect(ev.participant, null); expect(ev.details.name, "toexpire"); expect(ev.fault, DkgFault.expired); // No DKGs should exist expect(tc.client.dkgRequests, isEmpty); - }); group("given a round 2 DKG", () { - late TestClient tc; late List part1s; late DkgCommitmentSet commitmentSet; late Uint8List commonHash; setUp(() async { - tc = await login(9); await tc.client.requestDkg(dkgDetails.obj); @@ -841,7 +813,6 @@ void main() { // Flush events we do not care about await tc.evCollector.getEvents(); - }); test( @@ -865,21 +836,21 @@ void main() { ); Future expectDkgRejectionOnEvent( - Event sendEv, DkgFault fault, [bool hasCulprit = true,] - ) async { - + Event sendEv, + DkgFault fault, [ + bool hasCulprit = true, + ]) async { ctx.api.state.sendEventToAll(sendEv); await tc.evCollector.expectNoError(); - final ev = await tc.evCollector - .getExpectOneEvent(); + final ev = + await tc.evCollector.getExpectOneEvent(); expect(ev.participant, hasCulprit ? ids.first : null); expect(ev.details.name, "123"); expect(ev.fault, fault); expectNoDkgs(tc.client); - } test( @@ -891,7 +862,6 @@ void main() { ); test("handles logout causing DKG to return to round 1", () async { - ctx.api.state.sendEventToAll( ParticipantStatusEvent(id: ids.first, loggedIn: false), ); @@ -901,7 +871,6 @@ void main() { expect(tc.client.acceptedDkgs, isEmpty); expect(tc.client.dkgRequests, hasLength(1)); expect(tc.client.dkgRequests.first.completed, isEmpty); - }); test( @@ -918,7 +887,8 @@ void main() { DkgRound2ShareEvent( name: "123", commitmentSetSignature: cl.SchnorrSignature.sign( - getPrivkey(0), commonHash, + getPrivkey(0), + commonHash, ), sender: ids.first, secret: DkgEncryptedSecret( @@ -934,69 +904,71 @@ void main() { ), ); - test( - "handles wrong secret", - () async { + test("handles wrong secret", () async { + // Send in bad secret + ctx.api.state.sendEventToAll( + getRound2ShareEvent(commitmentSet, 0, 9, part1s[1], commonHash), + ); - // Send in bad secret + // Send in all but one good secrets + for (int i = 1; i < 8; i++) { ctx.api.state.sendEventToAll( - getRound2ShareEvent(commitmentSet, 0, 9, part1s[1], commonHash), + getRound2ShareEvent(commitmentSet, i, 9, part1s[i], commonHash), ); + } - // Send in all but one good secrets - for (int i = 1; i < 8; i++) { - ctx.api.state.sendEventToAll( - getRound2ShareEvent(commitmentSet, i, 9, part1s[i], commonHash), - ); - } - - final evs = await tc.evCollector.getEvents(); - expect(evs, hasLength(8)); - expect(evs.any((e) => e is! UpdatedDkgClientEvent), false); - - // Rejection happens on final event because only when all secrets are - // obtained can failured be determined - await expectDkgRejectionOnEvent( - getRound2ShareEvent(commitmentSet, 8, 9, part1s[8], commonHash), - DkgFault.secret, - false, - ); + final evs = await tc.evCollector.getEvents(); + expect(evs, hasLength(8)); + expect(evs.any((e) => e is! UpdatedDkgClientEvent), false); - } - ); + // Rejection happens on final event because only when all secrets are + // obtained can failured be determined + await expectDkgRejectionOnEvent( + getRound2ShareEvent(commitmentSet, 8, 9, part1s[8], commonHash), + DkgFault.secret, + false, + ); + }); test("handles duplicate secret share", () async { final ev = getRound2ShareEvent( - commitmentSet, 1, 9, part1s[1], commonHash, + commitmentSet, + 1, + 9, + part1s[1], + commonHash, ); ctx.api.state.sendEventToAll(ev); await tc.evCollector.expectOnlyOneEventType(); await expectBadEvent(tc, ev); }); - }); Future loginWithOwnAck( - int i, [ Set otherAcks = const {}, ] - ) => login( - i, - storage: storeWithKeyAndAcks(i, { getDkgAck(i, true), ...otherAcks }), - ); + int i, [ + Set otherAcks = const {}, + ]) => + login( + i, + storage: storeWithKeyAndAcks(i, {getDkgAck(i, true), ...otherAcks}), + ); test("ask and receive DKGs on logins", () async { - final acks = List.generate(10, (i) => getDkgAck(i, true)); // Give 4 acks to server - final ackCache = ctx.api.state.dkgAckCache[groupPublicKey] - = DkgAckCache(Expiry(Duration(days: 1))); + final ackCache = ctx.api.state.dkgAckCache[groupPublicKey] = + DkgAckCache(Expiry(Duration(days: 1))); for (int i = 0; i < 4; i++) { ackCache.acks[ids[i]] = acks[i].signed; } // Give 2 of the same acks and 2 different to client - final tc1 = await loginWithOwnAck(0, { acks[1], acks[4], acks[5] },); + final tc1 = await loginWithOwnAck( + 0, + {acks[1], acks[4], acks[5]}, + ); // Receive 2 of them from server so it now has first 6 await tc1.store.waitForKeyWithName("123", 6); @@ -1006,7 +978,12 @@ void main() { // Plus 2 others final tc2 = await loginWithOwnAck( 1, - { acks[2], acks[5], acks[6], acks[7], }, + { + acks[2], + acks[5], + acks[6], + acks[7], + }, ); // Client 1 should receive 2 others @@ -1025,13 +1002,12 @@ void main() { await tc.store.waitForKeyWithName("123", 10); await tc.expectOnlyLoginEvents(); } - }); test("gives negative ACK without key", () async { - Future expectAcks( - TestClient tc, List<(int, bool)> expected, + TestClient tc, + List<(int, bool)> expected, ) async { await waitFor( () => tc.store.keys.values.first.acks.length == expected.length, @@ -1040,7 +1016,11 @@ void main() { expect(actual, hasLength(expected.length)); for (final (expI, expAccepted) in expected) { expect( - actual.firstWhere((ack) => ack.signer == ids[expI]).signed.obj.accepted, + actual + .firstWhere((ack) => ack.signer == ids[expI]) + .signed + .obj + .accepted, expAccepted, ); } @@ -1050,7 +1030,7 @@ void main() { final tc1 = await loginWithOwnAck(0); // Login client 2 that provides NACK for third client - final tc2 = await loginWithOwnAck(1, { getDkgAck(2, false) }); + final tc2 = await loginWithOwnAck(1, {getDkgAck(2, false)}); // Client 1 & 2 should now have client 1 and 2 ACK and id 3 NACK final expAcks = [(0, true), (1, true), (2, false)]; @@ -1097,11 +1077,9 @@ void main() { expAcks[3] = (3, true); await waitFor(() => tc1.store.keys.values.first.acceptedAcks == 4); await expectAcks(tc1, expAcks); - }); test("handles bad DkgAckEvent", () async { - final ackWithId = getDkgAck(0, true); final ack1 = ackWithId.signed; @@ -1109,22 +1087,21 @@ void main() { await expectBadEvent( await loginWithOwnAck(0), DkgAckEvent( - { SignedDkgAck(signer: badId, signed: ack1) }, + {SignedDkgAck(signer: badId, signed: ack1)}, ), ); // Wrong identifier, bad signature await expectBadEvent( await loginWithOwnAck(0), - DkgAckEvent({ SignedDkgAck(signer: ids[1], signed: ack1) }), + DkgAckEvent({SignedDkgAck(signer: ids[1], signed: ack1)}), ); // Can't be self await expectBadEvent( await loginWithOwnAck(0), - DkgAckEvent({ ackWithId }), + DkgAckEvent({ackWithId}), ); - }); test("handles bad DkgAckRequestEvent", () async { @@ -1132,7 +1109,9 @@ void main() { await expectBadEvent( await loginWithOwnAck(0), DkgAckRequestEvent( - { DkgAckRequest(ids: {badId}, groupPublicKey: groupPublicKey) }, + { + DkgAckRequest(ids: {badId}, groupPublicKey: groupPublicKey) + }, ), ); }); @@ -1141,13 +1120,12 @@ void main() { final tc = await TestClient.login( MockUnrequestedAckApi(), 0, - storage: storeWithKeyAndAcks(0, { getDkgAck(0, true) }), + storage: storeWithKeyAndAcks(0, {getDkgAck(0, true)}), ); await tc.evCollector.expectError(); }); group("given all clients with 3-threshold key", () { - late SignaturesRequestDetails reqDetails; // Assign with 3-of-10 and 6-of-10 late List> infosForKeys; @@ -1179,46 +1157,47 @@ void main() { tcs[i] = await loginOne(i); } - SignaturesRequestDetails getSigDetailsWithKeys( - { - Expiry? expiry, - List? keys, - } - ) => SignaturesRequestDetails.allowNegativeExpiry( - requiredSigs: (keys ?? groupKeys).map( - (key) => SingleSignatureDetails( - signDetails: getSignDetails(0), - groupKey: key, - hdDerivation: [0], - ), - ).toList(), - expiry: expiry ?? futureExpiry, - ); + SignaturesRequestDetails getSigDetailsWithKeys({ + Expiry? expiry, + List? keys, + }) => + SignaturesRequestDetails.allowNegativeExpiry( + requiredSigs: (keys ?? groupKeys) + .map( + (key) => SingleSignatureDetails( + signDetails: getSignDetails(0), + groupKey: key, + hdDerivation: [0], + ), + ) + .toList(), + expiry: expiry ?? futureExpiry, + ); void expectNoSecretsInFirst() => expect( - stores.first.keys.values.map((key) => key.keyConstruction), - everyElement( - isA() - .having( - (construction) => construction.secrets, - ".secrets", - isEmpty, - ), - ), - ); + stores.first.keys.values.map((key) => key.keyConstruction), + everyElement( + isA().having( + (construction) => construction.secrets, + ".secrets", + isEmpty, + ), + ), + ); setUp(() async { - infosForKeys = [generateNewKey(3), generateNewKey(6)]; - groupKeys = infosForKeys.map( - (keyInfos) => keyInfos.first.groupKey, - ).toList(); + groupKeys = infosForKeys + .map( + (keyInfos) => keyInfos.first.groupKey, + ) + .toList(); stores = List.generate( 10, (i) { final store = InMemoryClientStorage(); - for (final j in [0,1]) { + for (final j in [0, 1]) { store.addOrReplaceFrostKey( FrostKeyWithDetails( keyInfo: infosForKeys[j][i], @@ -1241,11 +1220,9 @@ void main() { ); await loginAll(); - }); test("requestSignatures success", () async { - // First client creates request await tcs.first.client.requestSignatures(reqDetails); @@ -1264,7 +1241,7 @@ void main() { await tcs.first.evCollector.expectNoEventsOrError(); for (final tc in tcs.skip(1)) { final ev = await tc.evCollector - .getExpectOneEvent(); + .getExpectOneEvent(); expectRequest(ev.request, SignaturesRequestStatus.waiting); } @@ -1274,6 +1251,7 @@ void main() { expect(reqs, hasLength(1)); expectRequest(reqs.first, status); } + expectRequests(tcs.first, SignaturesRequestStatus.accepted); for (final tc in tcs.skip(1)) { expectRequests(tc, SignaturesRequestStatus.waiting); @@ -1289,19 +1267,17 @@ void main() { // Nonces exist in storage expect(stores.first.sigNonces, contains(reqDetails.id)); - }); test("requestSignatures failure", () async { - // Already existing request await tcs.first.client.requestSignatures(reqDetails); - Future expectFail(SignaturesRequestDetails details) - => expectLater( - () => tcs.first.client.requestSignatures(details), - throwsArgumentError, - ); + Future expectFail(SignaturesRequestDetails details) => + expectLater( + () => tcs.first.client.requestSignatures(details), + throwsArgumentError, + ); // Bad expiry for (final duration in [ @@ -1316,7 +1292,6 @@ void main() { // Non-existant key using details without stored key await expectFail(getSignaturesDetails()); - }); test( @@ -1327,7 +1302,6 @@ void main() { ); test("handles incorrect signatures request event", () async { - Future expectBadSigReqEv( Signed details, Identifier id, @@ -1363,9 +1337,8 @@ void main() { // Cannot receive signatures request we already have await tcs[1].client.requestSignatures(reqDetails); - await tcs.first.evCollector.expectOnlyOneEventType< - SignaturesRequestClientEvent - >(); + await tcs.first.evCollector + .expectOnlyOneEventType(); await expectBadEvent( tcs.first, SignaturesRequestEvent( @@ -1373,11 +1346,9 @@ void main() { creator: ids[1], ), ); - }); test("signatures request expiry is clamped", () async { - final reqExp = Expiry(Duration(days: 15)); final details = getSigDetailsWithKeys(expiry: reqExp); @@ -1390,7 +1361,7 @@ void main() { ); final ev = await tcs.first.evCollector - .getExpectOneEvent(); + .getExpectOneEvent(); // Details are the same expect(ev.request.details.id, details.id); @@ -1404,11 +1375,9 @@ void main() { ev.request.expiry.time, tcs.first.client.signaturesRequests.first.expiry.time, ); - }); test("signatures request auto rejected for missing keys", () async { - // Give first client a key that others do not have final otherKey = generateNewKey(3).first; await tcs.first.store.addOrReplaceFrostKey( @@ -1452,7 +1421,6 @@ void main() { expect(evs.take(2), everyElement(isA())); final ev = evs.last as SignaturesFailureClientEvent; expect(ev.request.details.id, sigDetails.id); - }); test("ignore signatures request that doesn't exist", () async { @@ -1461,7 +1429,6 @@ void main() { }); test("remove signatures request upon expiry", () async { - final state = getHiddenClientStateForTestsDoNotUse(tcs.first.client); state.sigRequests[reqDetails.id] = ClientSigsState( details: reqDetails, @@ -1471,16 +1438,14 @@ void main() { // Should get an expiry event final ev = await tcs.first.evCollector - .getExpectOneEvent(); + .getExpectOneEvent(); expect(ev.request.details.id, reqDetails.id); // No requests should exist expect(tcs.first.client.signaturesRequests, isEmpty); - }); test("handles premature completed signatures", () async { - final mockServ = MockPrematureSigsApi(); final newTcs = await Future.wait( List.generate( @@ -1495,11 +1460,9 @@ void main() { await expectMisbehaviour( () => newTcs.first.client.acceptSignaturesRequest(reqDetails.id), ); - }); group("given signature request", () { - late SigningCommitment firstCommitment; late SignatureRoundStart validRound; late SignaturesCoordinationState sigState; @@ -1507,7 +1470,6 @@ void main() { late List badNewRounds; setUp(() async { - await tcs.first.client.requestSignatures(reqDetails); sigState = ctx.api.state.sigRequests.values.first; @@ -1516,9 +1478,9 @@ void main() { await tc.evCollector.getEvents(); } - firstCommitment = ( - sigState.sigs.first as SingleSignatureInProgressState - ).nextCommitments[ids.first]!; + firstCommitment = + (sigState.sigs.first as SingleSignatureInProgressState) + .nextCommitments[ids.first]!; validRound = SignatureRoundStart( sigI: 0, @@ -1532,8 +1494,7 @@ void main() { // Get a valid signature for the first requested signature final part1s = List.generate(3, (i) => getSignPart1()); final commitments = SigningCommitmentSet({ - for (int i = 0; i < 3; i++) - ids[i]: part1s[i].commitment, + for (int i = 0; i < 3; i++) ids[i]: part1s[i].commitment, }); final sigDetails = reqDetails.requiredSigs.first; final shares = List.generate( @@ -1543,9 +1504,11 @@ void main() { details: sigDetails.signDetails, ourNonces: part1s[i].nonces, commitments: commitments, - info: sigDetails.derive( - HDParticipantKeyInfo.masterFromInfo(infosForKeys.first[i]), - ).signing, + info: sigDetails + .derive( + HDParticipantKeyInfo.masterFromInfo(infosForKeys.first[i]), + ) + .signing, ), ); validFirstSig = SignatureAggregation( @@ -1560,22 +1523,22 @@ void main() { ).signature; badNewRounds = [ - for (final multiRounds in [ // Empty rounds [], // Duplicate round [validRound, validRound], - ]) SignatureNewRoundsEvent( - reqId: reqDetails.id, - rounds: multiRounds, - ), + ]) + SignatureNewRoundsEvent( + reqId: reqDetails.id, + rounds: multiRounds, + ), for (final singleRound in [ // Incorrect number of commitments SignatureRoundStart( sigI: 0, - commitments: SigningCommitmentSet({ ids.first: firstCommitment }), + commitments: SigningCommitmentSet({ids.first: firstCommitment}), ), // Doesn't contain participant SignatureRoundStart( @@ -1594,27 +1557,27 @@ void main() { badId: getSignPart1().commitment, }), ), - ]) SignatureNewRoundsEvent( - reqId: reqDetails.id, - rounds: [singleRound], - ), + ]) + SignatureNewRoundsEvent( + reqId: reqDetails.id, + rounds: [singleRound], + ), // Signature out of range - for (final badI in [2, -1]) SignatureNewRoundsEvent( - reqId: reqDetails.id, - rounds: [ - SignatureRoundStart( - sigI: badI, commitments: validRound.commitments, - ), - ], - ), - + for (final badI in [2, -1]) + SignatureNewRoundsEvent( + reqId: reqDetails.id, + rounds: [ + SignatureRoundStart( + sigI: badI, + commitments: validRound.commitments, + ), + ], + ), ]; - }); test("invalid login sigRounds", () async { - final signedDetails = signObject(reqDetails); final validEv = SignatureNewRoundsEvent( reqId: reqDetails.id, @@ -1624,7 +1587,9 @@ void main() { for (final badSigRounds in [ ...badNewRounds.map((nre) => [nre]), // Missing request - [SignatureNewRoundsEvent(reqId: missingReqId, rounds: [validRound])], + [ + SignatureNewRoundsEvent(reqId: missingReqId, rounds: [validRound]) + ], // Duplicate request [validEv, validEv], ]) { @@ -1642,11 +1607,9 @@ void main() { ); await expectMisbehaviour(() => login(0, storage: stores.first)); } - }); test("invalid login completedSigs", () async { - // Create new request requiring only one 3-of-3 sig final singleReq = getSigDetailsWithKeys( keys: groupKeys.take(1).toList(), @@ -1700,18 +1663,17 @@ void main() { ); await login(0, storage: stores.first); - }); - void expectStatus(TestClient tc, SignaturesRequestStatus status) - => expect(tc.client.signaturesRequests.first.status, status); + void expectStatus(TestClient tc, SignaturesRequestStatus status) => + expect(tc.client.signaturesRequests.first.status, status); void expectRejectors(Set ids) => expect( - sigState.rejectors, ids, - ); + sigState.rejectors, + ids, + ); test("signature requests can be rejected", () async { - // 1-4 reject, leaving 0 and 5-9 able to sign for (final tc in tcs.skip(1).take(4)) { await tc.client.rejectSignaturesRequest(reqDetails.id); @@ -1737,7 +1699,7 @@ void main() { // Everyone gets failure event and signature request is removed for (final tc in tcs) { final ev = await tc.evCollector - .getExpectOneEvent(); + .getExpectOneEvent(); expect(ev.request.details.id, reqDetails.id); expect(tc.client.signaturesRequests, isEmpty); expect(tc.store.sigsRejected, isEmpty); @@ -1745,7 +1707,6 @@ void main() { } expect(ctx.api.state.sigRequests.values, isEmpty); - }); test("ignore SignatureNewRoundsEvent for missing request", () async { @@ -1757,8 +1718,9 @@ void main() { Future waitForAndExpectRejected(int i) async { await waitFor( - () => tcs[i].client.signaturesRequests.first.status - == SignaturesRequestStatus.rejected, + () => + tcs[i].client.signaturesRequests.first.status == + SignaturesRequestStatus.rejected, ); expectRejectors({ids[i]}); } @@ -1791,7 +1753,6 @@ void main() { }); Future expectRejectAfterReloginAndRound() async { - await tcs.first.client.logout(); // Get to 3-of-3 round @@ -1802,7 +1763,6 @@ void main() { // Login again and reject signature as a result of not having nonce tcs.first = await login(0, storage: tcs.first.store); await waitForAndExpectRejected(0); - } test("reject request if missing nonce for round on login", () async { @@ -1811,13 +1771,11 @@ void main() { }); test("reject request if wrong nonce for round on login", () async { - tcs.first.store.sigNonces.values.first.map[0] - = getSignPart1().nonces; + tcs.first.store.sigNonces.values.first.map[0] = getSignPart1().nonces; await expectRejectAfterReloginAndRound(); }); test("invalid SignatureNewRoundsEvent", () async { - for (final badEv in badNewRounds) { await expectBadEventRelogin(0, badEv); } @@ -1834,7 +1792,6 @@ void main() { sendEventToClient(tcs.first, newRoundEv); await tcs.first.evCollector.expectNoError(); await expectBadEvent(tcs.first, newRoundEv); - }); test("ignore SignaturesCompleteEvent for missing request", () async { @@ -1848,7 +1805,6 @@ void main() { }); test("invalid SignaturesCompleteEvent", () async { - // No signatures await expectBadEventRelogin( 0, @@ -1856,47 +1812,47 @@ void main() { ); for ( - // Only one sig, or incorrect sig for second - final sigs in [[validFirstSig], [validFirstSig, validFirstSig]] - ) { + // Only one sig, or incorrect sig for second + final sigs in [ + [validFirstSig], + [validFirstSig, validFirstSig] + ]) { await expectBadEventRelogin( 0, SignaturesCompleteEvent(reqId: reqDetails.id, signatures: sigs), ); } - }); Future massAccept(Iterable tcs) => Future.wait( - tcs.map((tc) => tc.client.acceptSignaturesRequest(reqDetails.id)), - ); + tcs.map((tc) => tc.client.acceptSignaturesRequest(reqDetails.id)), + ); Future waitForSig() => waitFor( - () => ctx.api.state.completedSigs.values.isNotEmpty, - ); + () => ctx.api.state.completedSigs.values.isNotEmpty, + ); Future expectSigsEv(TestClient tc) async { final ev = await tc.evCollector - .getExpectOneEvent(); + .getExpectOneEvent(); expect(ev.details.id, reqDetails.id); expect(ev.creator, ids.first); expect(ev.signatures, hasLength(2)); } Future expectNoEvents() => Future.wait( - tcs.map((tc) => tc.evCollector.expectNoEvents()), - ); + tcs.map((tc) => tc.evCollector.expectNoEvents()), + ); - Future expectOnlyStatusEvents(Iterable tcs) - => Future.wait( - tcs.map( - (tc) => tc.evCollector - .expectOnlyOneEventType(), - ), - ); + Future expectOnlyStatusEvents(Iterable tcs) => + Future.wait( + tcs.map( + (tc) => tc.evCollector + .expectOnlyOneEventType(), + ), + ); test("can create valid signature", () async { - // Last logs out to come back to signatures later await tcs.last.logout(); await expectOnlyStatusEvents(tcs.take(9)); @@ -1925,11 +1881,9 @@ void main() { expect(tc.store.sigNonces, hasLength(0)); expect(tc.store.sigsRejected, hasLength(0)); } - }); test("can approve after rejection and complete next round", () async { - // Approve 5 await massAccept(tcs.take(5)); @@ -1949,11 +1903,9 @@ void main() { for (final tc in tcs) { await expectSigsEv(tc); } - }); test("can continue round after re-login", () async { - // Approve another await massAccept(tcs.take(2)); @@ -1983,67 +1935,61 @@ void main() { for (final tc in tcs) { await expectSigsEv(tc); } - }); - }); group(".shareKeySecret", () { - test("failure", () async { - Future expectFailure( cl.ECCompressedPublicKey groupKey, Set toWhom, - ) => expectLater( - () => tcs.first.client.shareKeySecret(groupKey, toWhom: toWhom), - throwsArgumentError, - ); + ) => + expectLater( + () => tcs.first.client.shareKeySecret(groupKey, toWhom: toWhom), + throwsArgumentError, + ); // Group key doesn't exist - await expectFailure(groupPublicKey, { ids.last }); + await expectFailure(groupPublicKey, {ids.last}); // Cannot send to self - await expectFailure(groupKeys.first, { ids.first, ids.last }); + await expectFailure(groupKeys.first, {ids.first, ids.last}); // Participants must exist await expectFailure( groupKeys.first, - { ids.last, badId }, + {ids.last, badId}, ); - }); test("sucessful sharing and construction", () async { - - Future doShare(int i, [ Set? to ]) - => tcs[i].client.shareKeySecret( - groupKeys.first, - toWhom: to?.map((i) => ids[i]).toSet(), - ); + Future doShare(int i, [Set? to]) => + tcs[i].client.shareKeySecret( + groupKeys.first, + toWhom: to?.map((i) => ids[i]).toSet(), + ); void expectCompleted(KeyConstruction construction) => expect( - construction, - isA().having( - (construction) => construction.privateKey.pubkey, - ".privateKey.pubkey", - groupKeys.first, - ), - ); + construction, + isA().having( + (construction) => construction.privateKey.pubkey, + ".privateKey.pubkey", + groupKeys.first, + ), + ); Future expectShareEvents( int i, Iterable from, bool startCompleted, ) async { - final evs = await tcs[i].evCollector.getEvents(); // Discard login events final shareEvs = evs - .where((ev) => ev is! ParticipantStatusClientEvent) - .cast() - .toList(); + .where((ev) => ev is! ParticipantStatusClientEvent) + .cast() + .toList(); expect(shareEvs, hasLength(from.length)); expect( @@ -2052,7 +1998,6 @@ void main() { ); for (final ev in shareEvs) { - final construction = ev.keyDetails.keyConstruction; if (!startCompleted) { @@ -2068,20 +2013,18 @@ void main() { } else { expectCompleted(construction); } - } - } // Logout third await tcs[2].logout(); // Share secret of 1st to 2nd, 3rd - await doShare(0, {1,2}); + await doShare(0, {1, 2}); await expectShareEvents(1, {0}, false); // Shares secret of 2nd to 1st 3rd, 4th - await doShare(1, {0,2,3}); + await doShare(1, {0, 2, 3}); await expectShareEvents(0, {1}, false); await expectShareEvents(3, {1}, false); @@ -2115,7 +2058,7 @@ void main() { // 2nd attempts to resend to 4th but it does nothing // 2nd also shares to 5th that completes // 4th attampts to resend but it already sent to everyone - await doShare(1, {3,4}); + await doShare(1, {3, 4}); await doShare(3); await expectShareEvents(4, {1}, true); for (final tc in tcs) { @@ -2124,13 +2067,10 @@ void main() { // 2nd, 3rd and 5th have completed. Ensure others have claimedToHave for (final tc in tcs.skip(1)) { - expect( - tc.store.keys.values.first.claimedToHave, - { - for (final i in {1,2,4}) - if (ids[i] != tc.client.config.id) ids[i], - } - ); + expect(tc.store.keys.values.first.claimedToHave, { + for (final i in {1, 2, 4}) + if (ids[i] != tc.client.config.id) ids[i], + }); } // Server close and reopen @@ -2141,22 +2081,21 @@ void main() { tcs[0] = await loginOne(0); tcs[3] = await loginOne(3); await expectShareEvents(0, {3}, true); - }); test("ignores invalid share", () async { - void sendEvent( Identifier sender, cl.ECCompressedPublicKey key, - ) => sendEventToClient( - tcs.first, - SecretShareEvent( - sender: sender, - keyShare: validKeyShare, - groupKey: key, - ), - ); + ) => + sendEventToClient( + tcs.first, + SecretShareEvent( + sender: sender, + keyShare: validKeyShare, + groupKey: key, + ), + ); Future ignoresInvalid( Identifier sender, @@ -2175,14 +2114,11 @@ void main() { // Check valid works sendEvent(ids.last, groupKeys.first); await tcs.first.evCollector - .getExpectOneEvent(); - + .getExpectOneEvent(); }); - }); test("invalid login secretShares", () async { - await tcs.first.logout(); for (final badId in [ids.first, badId]) { @@ -2199,13 +2135,12 @@ void main() { ); await expectMisbehaviour(() => login(0, storage: stores.first)); } - }); test("ignore wrong secretShares on login", () async { - Future loginWithEv( - Identifier sender, cl.ECCompressedPublicKey key, + Identifier sender, + cl.ECCompressedPublicKey key, ) async { ctx = TestContext( LoginRespMockApi( @@ -2232,16 +2167,13 @@ void main() { await loginWithEv(ids.last, groupKeys.first); expect( stores.first.keys.values.first.keyConstruction, - isA() - .having( + isA().having( (construction) => construction.secrets, ".secrets", hasLength(1), ), ); - }); - }); test("invalid SecretShareEvent", () async { @@ -2258,7 +2190,6 @@ void main() { }); test("invalid ConstructedKeyEvent", () async { - final constructedKey = Signed.sign( obj: KeyWasConstructed(groupPublicKey), key: getPrivkey(0), @@ -2271,8 +2202,6 @@ void main() { ConstructedKeyEvent(participant: id, constructedKey: constructedKey), ); } - }); - }); } diff --git a/test/context.dart b/test/context.dart index 034276b..d275e3e 100644 --- a/test/context.dart +++ b/test/context.dart @@ -11,7 +11,6 @@ import 'helpers.dart'; import 'sig_data.dart'; class EventCollector { - static final Finalizer> _finalizer = Finalizer((sub) => sub.cancel()); @@ -69,7 +68,6 @@ class EventCollector { expect(await getEvents(), everyElement(isA())); await expectNoError(); } - } typedef ClientEventCollector = EventCollector; @@ -81,7 +79,6 @@ class ServerTestClient extends EventCollector { } class LoginRespMockApi extends ServerApiHandler { - final List sigRequests; final List sigRounds; final List completedSigs; @@ -112,12 +109,10 @@ class LoginRespMockApi extends ServerApiHandler { events: upstream.events, ); } - } /// Gives false DkgAck that wasn't requested class MockUnrequestedAckApi extends ServerApiHandler { - MockUnrequestedAckApi() : super(config: serverConfig); @override @@ -140,11 +135,9 @@ class MockUnrequestedAckApi extends ServerApiHandler { ...upstream, }; } - } class MockPrematureSigsApi extends ServerApiHandler { - MockPrematureSigsApi() : super(config: serverConfig); @override @@ -152,12 +145,11 @@ class MockPrematureSigsApi extends ServerApiHandler { required SessionID sid, required SignaturesRequestId reqId, required List replies, - }) => Future.value(SignaturesCompleteResponse([dummySig])); - + }) => + Future.value(SignaturesCompleteResponse([dummySig])); } class TestContext { - late final ServerApiHandler api; final List clients = []; @@ -166,7 +158,6 @@ class TestContext { } Future login(int i) async { - final response = await api.login( groupFingerprint: groupConfig.fingerprint, participantId: ids[i], @@ -181,13 +172,12 @@ class TestContext { clients.add(client); return client; - } - Future> multiLogin(int n, { int skip = 0 }) - => Future.wait(List.generate(n, (i) => login(i+skip))); + Future> multiLogin(int n, {int skip = 0}) => + Future.wait(List.generate(n, (i) => login(i + skip))); - DkgState addDkg(Identifier creator, String name, { int threshold = 2 }) { + DkgState addDkg(Identifier creator, String name, {int threshold = 2}) { return api.state.nameToDkg[name] = DkgState( details: signObject(getDkgDetails(name: name, threshold: threshold)), creator: creator, @@ -196,18 +186,18 @@ class TestContext { } SignaturesCoordinationState addSigReq( - Identifier creator, - [ List tweaks = const [0], ] - ) { + Identifier creator, [ + List tweaks = const [0], + ]) { final signedDetails = signObject( getSignaturesDetails(singleSigTweaks: tweaks), ); - return api.state.sigRequests[signedDetails.obj.id] - = SignaturesCoordinationState( - details: signedDetails, - creator: creator, - keys: tweaks.map((t) => getAggregateKeyInfo(tweak: t)).toSet(), - ); + return api.state.sigRequests[signedDetails.obj.id] = + SignaturesCoordinationState( + details: signedDetails, + creator: creator, + keys: tweaks.map((t) => getAggregateKeyInfo(tweak: t)).toSet(), + ); } CompletedSignatures addCompletedSig( @@ -227,15 +217,19 @@ class TestContext { return completed; } - DkgState addDkgRound1(Identifier creator, String name, List whoCommit) - => addDkg(creator, name)..round1.commitments.addAll( - whoCommit.map((i) => (ids[i], getDkgPart1(i).public)).toList(), - ); + DkgState addDkgRound1(Identifier creator, String name, List whoCommit) => + addDkg(creator, name) + ..round1.commitments.addAll( + whoCommit.map((i) => (ids[i], getDkgPart1(i).public)).toList(), + ); DkgState addDkgRound2( - Identifier creator, String name, [ Uint8List? expectedHash, ] - ) => addDkg(creator, name) - ..round = DkgRound2State(expectedHash: expectedHash ?? Uint8List(32)); + Identifier creator, + String name, [ + Uint8List? expectedHash, + ]) => + addDkg(creator, name) + ..round = DkgRound2State(expectedHash: expectedHash ?? Uint8List(32)); Future clearEvents() async { for (final client in clients) { @@ -248,11 +242,9 @@ class TestContext { await client.expectNoEventsOrError(); } } - } class TestClient { - final Client client; final ClientEventCollector evCollector; final InMemoryClientStorage store; @@ -260,12 +252,11 @@ class TestClient { TestClient._(this.client, this.evCollector, this.store); static Future login( - ApiRequestInterface api, int i, { - InMemoryClientStorage? storage, - void Function()? onDisconnect, - } - ) async { - + ApiRequestInterface api, + int i, { + InMemoryClientStorage? storage, + void Function()? onDisconnect, + }) async { final store = storage ?? InMemoryClientStorage(); final client = await Client.login( config: getClientConfig(i), @@ -277,19 +268,18 @@ class TestClient { final evCollector = ClientEventCollector(client.events); return TestClient._(client, evCollector, store); - } Future logout() => client.logout(); - Future expectOnlyLoginEvents() - => evCollector.expectOnlyOneEventType(); + Future expectOnlyLoginEvents() => + evCollector.expectOnlyOneEventType(); - Future waitForNoSigsReqs() - => waitFor(() => client.signaturesRequests.isEmpty); + Future waitForNoSigsReqs() => + waitFor(() => client.signaturesRequests.isEmpty); Future waitForKeyConstructed() => waitFor( - () => store.keys.values.first.keyConstruction is KeyConstructionComplete, - ); - + () => + store.keys.values.first.keyConstruction is KeyConstructionComplete, + ); } diff --git a/test/data.dart b/test/data.dart index f57d8cf..a67e58e 100644 --- a/test/data.dart +++ b/test/data.dart @@ -2,11 +2,11 @@ import 'dart:typed_data'; import 'package:coinlib/coinlib.dart' as cl; import 'package:noosphere_roast_server/noosphere_roast_server.dart'; -final ids = List.generate(10, (i) => Identifier.fromUint16(i+1)); +final ids = List.generate(10, (i) => Identifier.fromUint16(i + 1)); final badId = Identifier.fromUint16(11); final _basePrivkey = cl.ECPrivateKey(Uint8List(32)..last = 1); -Uint8List _getScalar(int i) => Uint8List(32)..last = i+1; +Uint8List _getScalar(int i) => Uint8List(32)..last = i + 1; cl.ECPrivateKey getPrivkey(int i) => _basePrivkey.tweak(_getScalar(i))!; final groupConfig = GroupConfig( @@ -33,23 +33,27 @@ NewDkgDetails getDkgDetails({ String description = "", int threshold = 2, Expiry? expiry, -}) => NewDkgDetails.allowNegativeExpiry( - name: name, - description: description, - threshold: threshold, - expiry: expiry ?? futureExpiry, -); - -Signed signObject(T details, [ int i = 0, ]) - => Signed.sign(obj: details, key: getPrivkey(i)); +}) => + NewDkgDetails.allowNegativeExpiry( + name: name, + description: description, + threshold: threshold, + expiry: expiry ?? futureExpiry, + ); + +Signed signObject( + T details, [ + int i = 0, +]) => + Signed.sign(obj: details, key: getPrivkey(i)); DkgPart1 getDkgPart1(int i) => DkgPart1( - identifier: ids[i], - threshold: 2, - n: 10, -); + identifier: ids[i], + threshold: 2, + n: 10, + ); ClientConfig getClientConfig(int i) => ClientConfig( - id: ids[i], - group: groupConfig, -); + id: ids[i], + group: groupConfig, + ); diff --git a/test/frost_key_with_details_test.dart b/test/frost_key_with_details_test.dart index df33acd..5b17039 100644 --- a/test/frost_key_with_details_test.dart +++ b/test/frost_key_with_details_test.dart @@ -5,22 +5,22 @@ import 'sig_data.dart'; import 'test_keys.dart'; final oldVersionHex = -"035582770be7cf86a6328765af71fd359dce106204b099e9853f99d83022e3ea5104000a0000000000000000000000000000000000000000000000000000000000000000010384a901676249450c2d2fd440e85eb08fbd176aaf2e38ff97b73e4b6d5e4c241f000000000000000000000000000000000000000000000000000000000000000202fe75ca3fa68401630bf2a01a340ea57782c6fd16be347ebca7bb459cecf7ec8800000000000000000000000000000000000000000000000000000000000000030225f4458d071caece540ee096157f0979e1be9e85aa69ce83899f4d60b746363f0000000000000000000000000000000000000000000000000000000000000004037b7ab42b7e78e5a8eeec164745e6a2a74d70db4026936b24e12fcde10a05d4fc0000000000000000000000000000000000000000000000000000000000000005029d31dd521001760121c8463af7388c760a488c1b63e0b390f2ddddba64cf18070000000000000000000000000000000000000000000000000000000000000006023709ea3deaff2b05808452f5e73c3498827269fef8bb6d883dd4d45d27048ee00000000000000000000000000000000000000000000000000000000000000007029304d84bb0da8d2d0e6ceee3ff042e1a611d1e64c55efb96461c150e1068dc74000000000000000000000000000000000000000000000000000000000000000803588f4d66a21d7ac2b359847887edafe6922ebab393228a1f5fb8024abedf5b53000000000000000000000000000000000000000000000000000000000000000903803e02d574edb91593cc6f747eb3df57db0dda54e2de830f2c1a30661cc8bae4000000000000000000000000000000000000000000000000000000000000000a02fe323c7eaf26c8b4519eb77d987ffe8a0fda5b617c241353dc53ea612e51ecbd00000000000000000000000000000000000000000000000000000000000000013da4009ba798bae5f75b69eb17ed9c3166bfb6a7bcdfd98e9a69ee32d21652940854657374204b6579134465736372697074696f6e20666f72206b65790200000000000000000000000000000000000000000000000000000000000000000201318ebf69651d9a3a131ec15df35f0ae27aa45aec5c776411f854a1b57cff8f3151f9a727a3d20ae879b1efbec60f3fad47c94ff2d6db9b4f1d56431d350385cb0000000000000000000000000000000000000000000000000000000000000006006b100ec9fc7cceb0a53ee6cb7e84617cf0790ea971f2703449fc2dcfd7d95ab27221e2c782e73e06f6c939259018d08cc1c8019de3bf1304c2819b7c5182a1a4"; + "035582770be7cf86a6328765af71fd359dce106204b099e9853f99d83022e3ea5104000a0000000000000000000000000000000000000000000000000000000000000000010384a901676249450c2d2fd440e85eb08fbd176aaf2e38ff97b73e4b6d5e4c241f000000000000000000000000000000000000000000000000000000000000000202fe75ca3fa68401630bf2a01a340ea57782c6fd16be347ebca7bb459cecf7ec8800000000000000000000000000000000000000000000000000000000000000030225f4458d071caece540ee096157f0979e1be9e85aa69ce83899f4d60b746363f0000000000000000000000000000000000000000000000000000000000000004037b7ab42b7e78e5a8eeec164745e6a2a74d70db4026936b24e12fcde10a05d4fc0000000000000000000000000000000000000000000000000000000000000005029d31dd521001760121c8463af7388c760a488c1b63e0b390f2ddddba64cf18070000000000000000000000000000000000000000000000000000000000000006023709ea3deaff2b05808452f5e73c3498827269fef8bb6d883dd4d45d27048ee00000000000000000000000000000000000000000000000000000000000000007029304d84bb0da8d2d0e6ceee3ff042e1a611d1e64c55efb96461c150e1068dc74000000000000000000000000000000000000000000000000000000000000000803588f4d66a21d7ac2b359847887edafe6922ebab393228a1f5fb8024abedf5b53000000000000000000000000000000000000000000000000000000000000000903803e02d574edb91593cc6f747eb3df57db0dda54e2de830f2c1a30661cc8bae4000000000000000000000000000000000000000000000000000000000000000a02fe323c7eaf26c8b4519eb77d987ffe8a0fda5b617c241353dc53ea612e51ecbd00000000000000000000000000000000000000000000000000000000000000013da4009ba798bae5f75b69eb17ed9c3166bfb6a7bcdfd98e9a69ee32d21652940854657374204b6579134465736372697074696f6e20666f72206b65790200000000000000000000000000000000000000000000000000000000000000000201318ebf69651d9a3a131ec15df35f0ae27aa45aec5c776411f854a1b57cff8f3151f9a727a3d20ae879b1efbec60f3fad47c94ff2d6db9b4f1d56431d350385cb0000000000000000000000000000000000000000000000000000000000000006006b100ec9fc7cceb0a53ee6cb7e84617cf0790ea971f2703449fc2dcfd7d95ab27221e2c782e73e06f6c939259018d08cc1c8019de3bf1304c2819b7c5182a1a4"; void main() { group("FrostKeyWithDetails", () { - setUpAll(loadFrosty); test("can be read and written and mutated", () { - final name = "Test Key"; final description = "Description for key"; final keyInfos = generateNewKey(4); - final secrets = keyInfos.map( - (keyInfos) => keyInfos.private.share, - ).toList(); + final secrets = keyInfos + .map( + (keyInfos) => keyInfos.private.share, + ) + .toList(); var details = FrostKeyWithDetails( keyInfo: keyInfos.first, @@ -40,6 +40,7 @@ void main() { ), ); } + addAck(1, true); addAck(5, false); @@ -48,7 +49,6 @@ void main() { Set claimedToHave, int nOtherSecrets, ) { - // Convert to hex and then back again final hex = details.toHex(); details = FrostKeyWithDetails.fromHex(hex); @@ -63,15 +63,19 @@ void main() { expect( details.keyConstruction, nOtherSecrets < 3 - ? isA().having( - (progress) => progress.secrets.keys, - "secrets.keys", - ids.skip(1).take(nOtherSecrets), - ).having( - (progress) => progress.secrets.values.map((key) => key.data), - "secrets.values", - secrets.skip(1).take(nOtherSecrets).map((key) => key.data), - ): isA(), + ? isA() + .having( + (progress) => progress.secrets.keys, + "secrets.keys", + ids.skip(1).take(nOtherSecrets), + ) + .having( + (progress) => + progress.secrets.values.map((key) => key.data), + "secrets.values", + secrets.skip(1).take(nOtherSecrets).map((key) => key.data), + ) + : isA(), ); expect( @@ -79,11 +83,13 @@ void main() { 10, (i) => details.keyConstruction.haveForParticipant(ids[i]), ), - nOtherSecrets < 3 ? [ - false, - ...List.filled(nOtherSecrets, true), - ...List.filled(9-nOtherSecrets, false), - ] : List.filled(10, true), + nOtherSecrets < 3 + ? [ + false, + ...List.filled(nOtherSecrets, true), + ...List.filled(9 - nOtherSecrets, false), + ] + : List.filled(10, true), ); void expectAck(int i, bool accepted) { @@ -94,10 +100,11 @@ void main() { expect(ack.signed.obj.groupKey, groupKey); expect(ack.signed.verify(getPrivkey(i).pubkey), true); } + expectAck(1, true); expectAck(5, false); - } + expectDetails({}, {}, 0); // Add secret share time, claimed to have and add one to keyConstruction @@ -110,10 +117,12 @@ void main() { details = details.addClaimedToHave(ids.last); void expectWithTimesAndClaimed(int nOtherSecrets) => expectDetails( - { for (final id in ids.skip(1).take(3)) id: now, }, - {ids.last}, - nOtherSecrets, - ); + { + for (final id in ids.skip(1).take(3)) id: now, + }, + {ids.last}, + nOtherSecrets, + ); // Add 1 secret. Adding secret multiple times is a nop for (int i = 0; i < 3; i++) { @@ -133,11 +142,9 @@ void main() { details = details.addSecretShare(ids[i], secrets[i])!; expectWithTimesAndClaimed(3); } - }); test(".addSecretShare fails on incorrect secret", () { - final infos = generateNewKey(2); final details = FrostKeyWithDetails( keyInfo: infos.first, @@ -148,11 +155,9 @@ void main() { details.addSecretShare(ids[1], infos.first.private.share), isNull, ); - }); test("can update existing acks and secret share times", () { - var details = FrostKeyWithDetails( keyInfo: generateNewKey(3).first, name: "Some key", @@ -166,22 +171,19 @@ void main() { expect(details.acks.first.signed.obj.accepted, accepted); } - for ( - final time in [ - DateTime.fromMillisecondsSinceEpoch(2000), - DateTime.fromMillisecondsSinceEpoch(3000), - ] - ) { + for (final time in [ + DateTime.fromMillisecondsSinceEpoch(2000), + DateTime.fromMillisecondsSinceEpoch(3000), + ]) { details = details.addOrReplaceSecretShareTimes( - {ids[1]}, time, + {ids[1]}, + time, ); expect(details.secretShareTimes[ids[1]], time); } - }); test("can read data from before v3.0.0", () { - final details = FrostKeyWithDetails.fromHex(oldVersionHex); expect(details.acks, hasLength(2)); expect(details.name, "Test Key"); @@ -189,16 +191,12 @@ void main() { expect(details.claimedToHave, isEmpty); expect( details.keyConstruction, - isA() - .having( + isA().having( (construction) => construction.secrets, ".secrets", isEmpty, ), ); - }); - }); } - diff --git a/test/grpc_test.dart b/test/grpc_test.dart index 7f637e8..52826bb 100644 --- a/test/grpc_test.dart +++ b/test/grpc_test.dart @@ -12,31 +12,31 @@ import 'sig_data.dart'; import 'test_keys.dart'; void main() { - setUpAll(loadFrosty); group("GrpcClientApi + FrostNoosphereService", () { - // Port hopefully unused final port = 13543; late ServerApiHandler apiHandler; late grpc.Server server; grpc.ClientChannel getChannel() => grpc.ClientChannel( - "127.0.0.1", - port: port, - options: const grpc.ChannelOptions( - credentials: grpc.ChannelCredentials.insecure(), - ), - ); + "127.0.0.1", + port: port, + options: const grpc.ChannelOptions( + credentials: grpc.ChannelCredentials.insecure(), + ), + ); GrpcClientApi getApi() => GrpcClientApi(getChannel()); Future login( - int i, { void Function()? onDisconnect, } - ) async { + int i, { + void Function()? onDisconnect, + }) async { final client = await TestClient.login( - getApi(), i, + getApi(), + i, onDisconnect: onDisconnect, ); await client.expectOnlyLoginEvents(); @@ -53,16 +53,18 @@ void main() { tearDown(() => server.shutdown()); // Give wrong fingerprint and expect error - test("handles error", () => expectLater( - () => getApi().login( - groupFingerprint: Uint8List(32), - participantId: ids.first, + test( + "handles error", + () => expectLater( + () => getApi().login( + groupFingerprint: Uint8List(32), + participantId: ids.first, + ), + throwsA(isA()), ), - throwsA(isA()), - ),); + ); test("can login and logout with events", () async { - final clients = await Future.wait(List.generate(10, login)); // Logout first client @@ -78,11 +80,10 @@ void main() { () => client.client.onlineParticipants.length == 8, ); final ev = await client.evCollector - .getExpectOneEvent(); + .getExpectOneEvent(); expect(ev.id, ids.first); expect(ev.loggedIn, false); } - }); test("client handles server being offline", () async { @@ -108,13 +109,14 @@ void main() { final wireApi = pbrpc.NoosphereClient(getChannel()); final stream = wireApi.fetchEventStream(pbrpc.Bytes(data: Uint8List(16))); await expectLater( - () async { await for (final _ in stream) {} }, + () async { + await for (final _ in stream) {} + }, throwsA(isA()), ); }); group("given DKG request and clients", () { - late List tcs; setUp(() async { @@ -129,26 +131,22 @@ void main() { test("can reject DKG", () async { await tcs.last.client.rejectDkg("123"); for (final tc in tcs.take(9)) { - await waitFor(() => tc.client.dkgRequests.isEmpty); - final ev = await tc.evCollector - .getExpectOneEvent(); + final ev = + await tc.evCollector.getExpectOneEvent(); expect(ev.details.name, "123"); expect(ev.participant, ids.last); expect(ev.fault, DkgFault.none); - } }); group("given key and signatures request", () { - late SignaturesRequestId reqId; late cl.ECCompressedPublicKey groupKey; setUp(() async { - // All other clients accept DKG await Future.wait( tcs.skip(1).map((tc) => tc.client.acceptDkg("123")), @@ -161,7 +159,7 @@ void main() { for (final tc in tcs) { expect(tc.store.keys.values.first.name, "123"); await tc.evCollector - .expectOnlyOneEventType(); + .expectOnlyOneEventType(); } groupKey = cl.ECCompressedPublicKey.fromPubkey( @@ -185,13 +183,12 @@ void main() { // Expect all other clients to receive for (final tc in tcs.skip(1)) { await waitFor(() => tc.client.signaturesRequests.length == 1); - await tc.evCollector.getExpectOneEvent(); + await tc.evCollector + .getExpectOneEvent(); } - }); test("can reject request", () async { - // 9 total rejections causes failure for (final tc in tcs.take(9)) { await tc.client.rejectSignaturesRequest(reqId); @@ -200,10 +197,9 @@ void main() { for (final tc in tcs) { await tc.waitForNoSigsReqs(); final ev = await tc.evCollector - .getExpectOneEvent(); + .getExpectOneEvent(); expect(ev.request.details.id, reqId); } - }); Future logoutLast() async { @@ -215,14 +211,15 @@ void main() { Future reloginLast() async { tcs.last = await TestClient.login( - getApi(), 9, storage: tcs.last.store, + getApi(), + 9, + storage: tcs.last.store, ); } test( "can accept request and receive completed signature on login", () async { - // Logout last to receive signature on login await logoutLast(); @@ -233,7 +230,7 @@ void main() { TestClient tc, ) async { final ev = await tc.evCollector - .getExpectOneEvent(); + .getExpectOneEvent(); expect(ev.details.id, reqId); expect(ev.creator, ids.first); expect(ev.signatures, hasLength(1)); @@ -258,12 +255,10 @@ void main() { ev.signatures.first.verify(tr.tweakedKey, Uint8List(32)), true, ); - }, ); test("can construct underlying key", () async { - // Logout last to receive key on login await logoutLast(); @@ -271,26 +266,25 @@ void main() { await Future.wait( tcs.take(2).map((tc) => tc.client.shareKeySecret(groupKey)), ); - await Future.wait(tcs.take(9).map((tc) => tc.waitForKeyConstructed())); + await Future.wait( + tcs.take(9).map((tc) => tc.waitForKeyConstructed())); // Last logs in and obtains key await reloginLast(); await tcs.last.waitForKeyConstructed(); - }); - }); - }); test("can receive needed DKG acks", () async { - // Clients login with own ACKs List tcs = await Future.wait( List.generate( - 10, (i) => TestClient.login( - getApi(), i, - storage: storeWithKeyAndAcks(i, { getDkgAck(i, true) }), + 10, + (i) => TestClient.login( + getApi(), + i, + storage: storeWithKeyAndAcks(i, {getDkgAck(i, true)}), ), ), ); @@ -299,9 +293,6 @@ void main() { await Future.wait( tcs.map((tc) => tc.store.waitForKeyWithName("123", 10)), ); - }); - }); - } diff --git a/test/helpers.dart b/test/helpers.dart index 52001fd..d3185cf 100644 --- a/test/helpers.dart +++ b/test/helpers.dart @@ -4,13 +4,13 @@ import 'package:test/test.dart'; void writableTest( cl.Writable Function() getWritable, cl.Writable Function(cl.BytesReader) fromReader, -) => test("read/write", () { - final bytes = getWritable().toBytes(); - expect(fromReader(cl.BytesReader(bytes)).toBytes(), bytes); -}); +) => + test("read/write", () { + final bytes = getWritable().toBytes(); + expect(fromReader(cl.BytesReader(bytes)).toBytes(), bytes); + }); Future waitFor(bool Function() test) { - final start = DateTime.now(); final duration = Duration(seconds: 2); @@ -24,5 +24,4 @@ Future waitFor(bool Function() test) { } return cont; }); - } diff --git a/test/server/api_handler_test.dart b/test/server/api_handler_test.dart index fd7518e..825a102 100644 --- a/test/server/api_handler_test.dart +++ b/test/server/api_handler_test.dart @@ -14,20 +14,20 @@ import '../test_keys.dart'; void main() { group("ServerApiHander", () { - setUpAll(loadFrosty); late TestContext ctx; setUp(() => ctx = TestContext()); Future expectInvalid(void Function() f) async => await expectLater( - f, throwsA(isA()), - ); + f, + throwsA(isA()), + ); void expectSigned(Signed signed) => expect( - signed.verify(getPrivkey(0).pubkey), - true, - ); + signed.verify(getPrivkey(0).pubkey), + true, + ); Future expectOnlyLoginEventsForAll() async { for (final client in ctx.clients) { @@ -36,9 +36,7 @@ void main() { } group(".login()", () { - test("invalid request", () async { - // Invalid version await expectInvalid( () => ctx.api.login( @@ -63,11 +61,9 @@ void main() { participantId: ids.first, ), ); - }); test("success", () async { - final response = await ctx.api.login( groupFingerprint: groupConfig.fingerprint, participantId: ids.first, @@ -75,18 +71,14 @@ void main() { expect(ctx.api.state.challenges[response.challenge]!.id, ids.first); expect(response.expiry.isExpired, false); expect(response.challenge.n.length, 16); - }); - }); group(".respondToChallenge()", () { - late AuthChallenge challenge; late Signed validResp; setUp(() async { - final response = await ctx.api.login( groupFingerprint: groupConfig.fingerprint, participantId: ids.first, @@ -94,11 +86,9 @@ void main() { challenge = response.challenge; validResp = Signed.sign(obj: challenge, key: getPrivkey(0)); - }); test("invalid request", () async { - // No challenge await expectInvalid( () => ctx.api.respondToChallenge( @@ -112,15 +102,14 @@ void main() { Signed.sign(obj: challenge, key: getPrivkey(1)), ), ); - }); test("expired challenge", () async { - // Create challenge to be immediately expired for participant 2 final challenge = AuthChallenge(); ctx.api.state.challenges[challenge] = ChallengeDetails( - id: ids[1], expiry: Expiry(Duration(days: -1)), + id: ids[1], + expiry: Expiry(Duration(days: -1)), ); await expectInvalid( @@ -130,11 +119,9 @@ void main() { ); expect(ctx.api.state.challenges[challenge], null); - }); test("success", () async { - // Add round2 DKG that should be removed ctx.addDkgRound2(ids[1], "round2"); @@ -148,6 +135,7 @@ void main() { final commitments = ctx.api.state.nameToDkg[name]!.round1.commitments; expect(commitments.map((e) => e.$1), ids); } + void expectResetDkgInfo() { expectDkgCommitments("123", [ids[1]]); expectDkgCommitments("round2", []); @@ -185,6 +173,7 @@ void main() { expect(ev.creator, creator); expect(newDkg.commitments.map((c) => c.$1), ids); } + expectDkg(newDkgs.first, "123", ids.first, [ids[1]]); expectDkg(newDkgs.last, "round2", ids[1], []); @@ -202,11 +191,11 @@ void main() { // Other participant received logout and login event void expectLoginEvent(Event ev, bool loggedIn) => expect( - ev, - isA() - .having((e) => e.id, "id", ids[0]) - .having((e) => e.loggedIn, "loggedIn", loggedIn), - ); + ev, + isA() + .having((e) => e.id, "id", ids[0]) + .having((e) => e.loggedIn, "loggedIn", loggedIn), + ); { final events = await other.getEvents(); expect(events.length, 2); @@ -232,25 +221,23 @@ void main() { expectLoginEvent(events.first, false); } expectResetDkgInfo(); - }); test("provides signature data on success", () async { - // Add signature requests with pending rounds on second and third only final sigStates = [ - ctx.addSigReq(ids.first, [0,1]), - ctx.addSigReq(ids.first, [2,3,4]), - ctx.addSigReq(ids.first, [5,6]), + ctx.addSigReq(ids.first, [0, 1]), + ctx.addSigReq(ids.first, [2, 3, 4]), + ctx.addSigReq(ids.first, [5, 6]), ]; final sigIds = sigStates.map((state) => state.details.obj.id).toList(); SignatureRoundState addRound(int reqI, int sigI, Set ids) { - final sigState = sigStates[reqI].sigs[sigI] - as SingleSignatureInProgressState; + final sigState = + sigStates[reqI].sigs[sigI] as SingleSignatureInProgressState; final round = SignatureRoundState( SigningCommitmentSet( - { for (final id in ids) id: getSignPart1(i:0).commitment }, + {for (final id in ids) id: getSignPart1(i: 0).commitment}, ), ); for (final id in ids) { @@ -268,7 +255,8 @@ void main() { addRound(1, 1, ids.take(3).toSet()); // Signature 2 has share for participant already addRound(1, 2, ids.take(4).toSet()) - .shares.add((ids.first, dummyPart2().share)); + .shares + .add((ids.first, dummyPart2().share)); // Third request has rounds for all addRound(2, 0, ids.take(2).toSet()); @@ -303,7 +291,7 @@ void main() { } expectRounds(1, [1]); - expectRounds(2, [0,1]); + expectRounds(2, [0, 1]); // Completed sigs. Only include the one without the ACK expect(response.completedSigs, hasLength(1)); @@ -311,19 +299,17 @@ void main() { response.completedSigs.first.details.obj.id, noAckCompletedSigs.details.obj.id, ); - }); - }); group(".extendSession()", () { - late SessionID sid; setUp(() async => sid = (await ctx.login(0)).sid); test( "invalid request", - () async => await expectInvalid(() => ctx.api.extendSession(SessionID())), + () async => + await expectInvalid(() => ctx.api.extendSession(SessionID())), ); test("success", () async { @@ -331,26 +317,21 @@ void main() { expect(newExpiry.isExpired, false); expect(ctx.api.state.clientSessions[sid]!.expiry.time, newExpiry.time); }); - }); group(".requestNewDkg()", () { - late ServerTestClient client; late DkgPublicCommitment commitment; setUp(() async { - client = await ctx.login(0); commitment = getDkgPart1(0).public; // Already existing DKG request ctx.addDkg(ids.first, "other"); - }); test("invalid request", () async { - // Invalid session id await expectInvalid( () => ctx.api.requestNewDkg( @@ -364,7 +345,7 @@ void main() { await expectInvalid( () => ctx.api.requestNewDkg( sid: client.sid, - signedDetails: signObject(getDkgDetails(name:"other")), + signedDetails: signObject(getDkgDetails(name: "other")), commitment: commitment, ), ); @@ -408,11 +389,9 @@ void main() { commitment: commitment, ), ); - }); test("success", () async { - // Add other participant session to obtain an event final other = await ctx.login(1); @@ -451,13 +430,10 @@ void main() { // Sending participant shouldn't receive event await client.expectNoEvents(); - }); - }); group(".rejectDkg()", () { - late ServerTestClient client, other; setUp(() async { @@ -468,14 +444,15 @@ void main() { }); test("invalid request", () async { - await expectInvalid(() => ctx.api.rejectDkg(sid: SessionID(), name: "123")); + await expectInvalid( + () => ctx.api.rejectDkg(sid: SessionID(), name: "123")); }); test("success", () async { - void expectExists(bool exists) => expect( - ctx.api.state.nameToDkg.containsKey("123"), exists, - ); + ctx.api.state.nameToDkg.containsKey("123"), + exists, + ); await ctx.api.rejectDkg(sid: client.sid, name: "other"); expectExists(true); @@ -490,16 +467,13 @@ void main() { expect( events.first, isA() - .having((e) => e.name, "name", "123") - .having((e) => e.participant, "participant", ids.first), + .having((e) => e.name, "name", "123") + .having((e) => e.participant, "participant", ids.first), ); - }); - }); group(".submitDkgCommitment()", () { - late List commitments; setUp(() async { @@ -547,11 +521,9 @@ void main() { }); test("success", () async { - await expectOnlyLoginEventsForAll(); for (int i = 1; i < 10; i++) { - await ctx.api.submitDkgCommitment( sid: ctx.clients[i].sid, name: "123", @@ -560,7 +532,6 @@ void main() { // Expect events for (final client in ctx.clients) { - if (client.sid == ctx.clients[i].sid) { await client.expectNoEvents(); continue; @@ -574,9 +545,7 @@ void main() { expect(ev.name, "123"); expect(ev.participant, ids[i]); expect(ev.commitment.toBytes(), commitments[i].public.toBytes()); - } - } // Expect moving onto round 2 @@ -584,19 +553,15 @@ void main() { ctx.api.state.nameToDkg["123"]!.round, isA(), ); - }); - }); group(".submitDkgRound2()", () { - late List part2s; late List commitmentSetSigs; late List> secretMaps; setUp(() async { - await ctx.multiLogin(10); final part1s = List.generate(10, (i) => getDkgPart1(i)); final commitmentSet = DkgCommitmentSet( @@ -618,24 +583,26 @@ void main() { 10, (i) => { for (int j = 0; j < 10; j++) - if (j != i) ids[j]: DkgEncryptedSecret.encrypt( - secretShare: part2s[i].sharesToGive[ids[j]]!, - recipientKey: getPrivkey(j).pubkey, - senderKey: getPrivkey(i), - ), + if (j != i) + ids[j]: DkgEncryptedSecret.encrypt( + secretShare: part2s[i].sharesToGive[ids[j]]!, + recipientKey: getPrivkey(j).pubkey, + senderKey: getPrivkey(i), + ), }, ); ctx.addDkg(ids.first, "round1"); // Add round 2 with last participant already having provided - ctx.addDkgRound2(ids.first, "round2", commitmentSet.hash) - .round2.participantsProvided.add(ids.last); - + ctx + .addDkgRound2(ids.first, "round2", commitmentSet.hash) + .round2 + .participantsProvided + .add(ids.last); }); test("invalid request", () async { - // Invalid session ID or already provided round 2 for (final badSid in [SessionID(), ctx.clients.last.sid]) { await expectInvalid( @@ -698,16 +665,13 @@ void main() { ), ); } - }); test("success", () async { - await expectOnlyLoginEventsForAll(); // All ctx.clients except last to give secrets for (int i = 0; i < 9; i++) { - await ctx.api.submitDkgRound2( sid: ctx.clients[i].sid, name: "round2", @@ -717,16 +681,15 @@ void main() { // Expect state to record participant except for last if (i < 8) { - final provided - = ctx.api.state.nameToDkg["round2"]!.round2.participantsProvided; - expect(provided.length, i+2); + final provided = + ctx.api.state.nameToDkg["round2"]!.round2.participantsProvided; + expect(provided.length, i + 2); expect(provided, contains(ids[i])); } // Expect all other ctx.clients to receive event for (int j = 0; j < 10; j++) { if (j != i) { - final events = await ctx.clients[j].getEvents(); expect(events.length, 1); expect( @@ -742,20 +705,16 @@ void main() { ev.secret.ciphertext.toBytes(), secretMaps[i][ids[j]]!.ciphertext.toBytes(), ); - } } } // Expect DKG state removed after all participants provided expect(ctx.api.state.nameToDkg["round2"], null); - }); - }); group(".sendDkgAcks()", () { - late List acks; setUp(() async { @@ -765,7 +724,6 @@ void main() { }); test("invalid request", () async { - // Invalid session ID await expectInvalid( () => ctx.api.sendDkgAcks(sid: SessionID(), acks: acks.toSet()), @@ -793,16 +751,13 @@ void main() { }, ), ); - }); test("success", () async { - await expectOnlyLoginEventsForAll(); // Add all ACKs and expect events for (int i = 0; i < 9; i++) { - // Allow any sender final senderI = i % 3; @@ -814,13 +769,11 @@ void main() { // Check events given to participants except for sender and signer for (int j = 0; j < 8; j++) { - final evs = await ctx.clients[j].getEvents(); if (j == i || j == senderI) { expect(evs, isEmpty); } else { - expect(evs.length, 1); final acks = (evs.first as DkgAckEvent).acks; @@ -833,33 +786,26 @@ void main() { expect(acks.last.signer, ids.last); expect(acks.last.signed.obj.accepted, false); } - } - } // Check state final ackMap = ctx.api.state.dkgAckCache[groupPublicKey]!.acks; - expect(ackMap.length, i+2); + expect(ackMap.length, i + 2); expect(ackMap, contains(ids[i])); expect(ackMap, contains(ids.last)); expect(ackMap[ids[i]]!.obj.accepted, i % 2 == 0); expect(ackMap[ids.last]!.obj.accepted, false); - } - }); - }); group(".requestDkgAcks()", () { - late cl.ECCompressedPublicKey altKey, altKey2; // The ones the server has and will be given to the first participant late Set toHave; setUp(() async { - // Not everyone is logged in await ctx.multiLogin(8); @@ -867,9 +813,8 @@ void main() { // Include three existing acks for main key final expiry = Expiry(Duration(minutes: 1)); - final cache - = ctx.api.state.dkgAckCache[groupPublicKey] - = DkgAckCache(expiry); + final cache = + ctx.api.state.dkgAckCache[groupPublicKey] = DkgAckCache(expiry); for (int i = 0; i < 3; i++) { final ack = getDkgAck(i, true); if (i != 0) toHave.add(ack); @@ -880,31 +825,32 @@ void main() { altKey = groupPublicKey.tweak(Uint8List(32)..last = 1)!; final ack = getDkgAck(1, true, groupKey: altKey); toHave.add(ack); - ctx.api.state.dkgAckCache[altKey] - = DkgAckCache(expiry)..acks[ids[1]] = ack.signed; + ctx.api.state.dkgAckCache[altKey] = DkgAckCache(expiry) + ..acks[ids[1]] = ack.signed; // Another key without a cached ACK altKey2 = groupPublicKey.tweak(Uint8List(32)..last = 2)!; await expectOnlyLoginEventsForAll(); - }); DkgAckRequest getReq( - Set idIs, - [ cl.ECCompressedPublicKey? key, ] - ) => DkgAckRequest( - ids: idIs.map((i) => ids[i]).toSet(), - groupPublicKey: key ?? groupPublicKey, - ); + Set idIs, [ + cl.ECCompressedPublicKey? key, + ]) => + DkgAckRequest( + ids: idIs.map((i) => ids[i]).toSet(), + groupPublicKey: key ?? groupPublicKey, + ); test("invalid request", () async { - // Invalid Session ID await expectInvalid( () => ctx.api.requestDkgAcks( sid: SessionID(), - requests: { getReq({0}) }, + requests: { + getReq({0}) + }, ), ); @@ -913,7 +859,7 @@ void main() { () => ctx.api.requestDkgAcks( sid: ctx.clients.first.sid, requests: { - DkgAckRequest(ids: { badId }, groupPublicKey: groupPublicKey), + DkgAckRequest(ids: {badId}, groupPublicKey: groupPublicKey), }, ), ); @@ -922,23 +868,23 @@ void main() { await expectInvalid( () => ctx.api.requestDkgAcks( sid: ctx.clients.first.sid, - requests: { getReq({0}) }, + requests: { + getReq({0}) + }, ), ); - }); test("success", () async { - // Ask for two cached ACKs from the first key and a cached ACK for the // second, two ACKs that doesn't exist for the first key and another ACK // for a key without a cache final haveAcks = await ctx.api.requestDkgAcks( sid: ctx.clients.first.sid, requests: { - getReq({ 1, 2, 3, 4 }), - getReq({ 1 }, altKey), - getReq({ 1 }, altKey2), + getReq({1, 2, 3, 4}), + getReq({1}, altKey), + getReq({1}, altKey2), }, ); @@ -949,7 +895,6 @@ void main() { // Other clients should receive requests for missing ACKs for (final client in ctx.clients.skip(1)) { - final evs = await client.getEvents(); expect(evs.length, 1); final reqs = (evs.first as DkgAckRequestEvent).requests; @@ -957,34 +902,32 @@ void main() { expect(reqs.length, 2); expect( reqs.firstWhere((req) => req.groupPublicKey == groupPublicKey).ids, - { ids[3], ids[4] }, + {ids[3], ids[4]}, ); expect( reqs.firstWhere((req) => req.groupPublicKey == altKey2).ids, - { ids[1] }, + {ids[1]}, ); } - }); test("do not send DkgAckRequestEvent when there are no needed", () async { - final haveAcks = await ctx.api.requestDkgAcks( sid: ctx.clients.first.sid, // Request only what the server has - requests: { getReq({ 1, 2 }), getReq({1}, altKey) }, + requests: { + getReq({1, 2}), + getReq({1}, altKey) + }, ); expect(haveAcks, toHave); // No events should be had as all ACKs were returned await ctx.expectNoEventsOrError(); - }); - }); group(".requestSignatures()", () { - late ServerTestClient client; late List keys; late Signed existing; @@ -994,35 +937,33 @@ void main() { late List validCommitments; setUp(() async { - client = await ctx.login(0); keys = List.generate(2, (i) => getAggregateKeyInfo(tweak: i)); existing = signObject(getSignaturesDetails(singleSigTweaks: [0xff])); validKeys = {keys[0]}; - keysForExisting = { getAggregateKeyInfo(tweak: 0xff) }; + keysForExisting = {getAggregateKeyInfo(tweak: 0xff)}; validDetails = getSignaturesDetails(); validSignedDetails = signObject(validDetails); validCommitments = [getSignPart1(tweak: 0).commitment]; ctx.addSigReq(ids.first, [0xff]); - }); test("invalid request", () async { - Future expectInvalidSigReq({ SessionID? sid, Set? keys, Signed? signedDetails, List? commitments, - }) => expectInvalid( - () => ctx.api.requestSignatures( - sid: sid ?? client.sid, - keys: keys ?? validKeys, - signedDetails: signedDetails ?? validSignedDetails, - commitments: commitments ?? validCommitments, - ), - ); + }) => + expectInvalid( + () => ctx.api.requestSignatures( + sid: sid ?? client.sid, + keys: keys ?? validKeys, + signedDetails: signedDetails ?? validSignedDetails, + commitments: commitments ?? validCommitments, + ), + ); // Invalid Session ID await expectInvalidSigReq(sid: SessionID()); @@ -1058,11 +999,9 @@ void main() { await expectInvalidSigReq( signedDetails: signObject(getSignaturesDetails(), 1), ); - }); test("success", () async { - // Add other participant session to obtain an event final other = await ctx.login(1); @@ -1083,7 +1022,7 @@ void main() { expect(req.sigs, hasLength(1)); expect( (req.sigs.first as SingleSignatureInProgressState) - .nextCommitments[ids.first], + .nextCommitments[ids.first], validCommitments.first, ); @@ -1095,13 +1034,10 @@ void main() { expect(ev.creator, ids.first); await client.expectNoEventsOrError(); - }); - }); group("given signatures request", () { - late List clients; late List k1shares; late List k2shares; @@ -1110,7 +1046,6 @@ void main() { late List creatorPart1s; setUp(() async { - clients = await ctx.multiLogin(10); // Request with two root keys: k1 and k2 @@ -1151,25 +1086,26 @@ void main() { expiry: Expiry(Duration(hours: 1)), ); - creatorPart1s = [k1shares, k1shares, k2shares, k1shares].map( - (li) => SignPart1(privateShare: li.first.private.share), - ).toList(); + creatorPart1s = [k1shares, k1shares, k2shares, k1shares] + .map( + (li) => SignPart1(privateShare: li.first.private.share), + ) + .toList(); await ctx.api.requestSignatures( sid: clients.first.sid, - keys: { k1shares.first.aggregate, k2shares.first.aggregate }, + keys: {k1shares.first.aggregate, k2shares.first.aggregate}, signedDetails: Signed.sign(obj: sigsDetails, key: getPrivkey(0)), commitments: creatorPart1s.map((part1) => part1.commitment).toList(), ); reqState = ctx.api.state.sigRequests[sigsDetails.id]!; - }); void expectSigReqExists(bool exists) => expect( - ctx.api.state.sigRequests.containsKey(sigsDetails.id), - exists, - ); + ctx.api.state.sigRequests.containsKey(sigsDetails.id), + exists, + ); Future expectFailedReq() async { for (final client in clients) { @@ -1182,7 +1118,6 @@ void main() { } group(".rejectSignaturesRequest()", () { - test("invalid request", () async { // Invalid Session ID await expectInvalid( @@ -1202,11 +1137,10 @@ void main() { ); test("success", () async { - Future doReject(int i) => ctx.api.rejectSignaturesRequest( - sid: clients[i].sid, - reqId: sigsDetails.id, - ); + sid: clients[i].sid, + reqId: sigsDetails.id, + ); await ctx.clearEvents(); @@ -1227,9 +1161,9 @@ void main() { void expectNearlyFailed() { expect( reqState.rejectors, - { ...ids.take(2), ...ids.skip(3).take(3) }, + {...ids.take(2), ...ids.skip(3).take(3)}, ); - expect(reqState.malicious, { ids[2] }); + expect(reqState.malicious, {ids[2]}); } expectNearlyFailed(); @@ -1244,25 +1178,24 @@ void main() { // Add one more rejection leading to failure await doReject(6); await expectFailedReq(); - }); - }); group(".submitSignatureReplies()", () { - HDParticipantKeyInfo deriveInfo( - ParticipantKeyInfo info, List indicies, - ) => indicies.fold( - HDParticipantKeyInfo.masterFromInfo(info), - (key, i) => key.derive(i), - ); + ParticipantKeyInfo info, + List indicies, + ) => + indicies.fold( + HDParticipantKeyInfo.masterFromInfo(info), + (key, i) => key.derive(i), + ); late List> sigInfos; setUp(() async { - final sharedk1Info - = k1shares.map((info) => deriveInfo(info, [0])).toList(); + final sharedk1Info = + k1shares.map((info) => deriveInfo(info, [0])).toList(); sigInfos = [ sharedk1Info, k1shares.map((info) => deriveInfo(info, [1, 0x7fffffff])).toList(), @@ -1272,31 +1205,33 @@ void main() { }); SignPart1 doPart1(int i, int sigI) => SignPart1( - privateShare: sigInfos[sigI][i].private.share, - ); + privateShare: sigInfos[sigI][i].private.share, + ); SignatureReply getReply( - int i, int sigI, { - SigningCommitment? commitment, - SigningNonces? nonce, - SigningCommitmentSet? commitments, - SignDetails? signDetailsOverride, - } - ) => SignatureReply( - sigI: sigI, - nextCommitment: (commitment ?? doPart1(i, sigI).commitment), - share: commitments == null ? null : SignPart2( - identifier: ids[i], - details: signDetailsOverride - ?? sigsDetails.requiredSigs[sigI].signDetails, - ourNonces: nonce!, - commitments: commitments, - info: sigInfos[sigI][i].signing, - ).share, - ); + int i, + int sigI, { + SigningCommitment? commitment, + SigningNonces? nonce, + SigningCommitmentSet? commitments, + SignDetails? signDetailsOverride, + }) => + SignatureReply( + sigI: sigI, + nextCommitment: (commitment ?? doPart1(i, sigI).commitment), + share: commitments == null + ? null + : SignPart2( + identifier: ids[i], + details: signDetailsOverride ?? + sigsDetails.requiredSigs[sigI].signDetails, + ourNonces: nonce!, + commitments: commitments, + info: sigInfos[sigI][i].signing, + ).share, + ); test("invalid request", () async { - final validResp = getReply(1, 0); // Invalid Session ID @@ -1350,7 +1285,7 @@ void main() { // Commitment exists final part1 = doPart1(1, 3); (reqState.sigs[3] as SingleSignatureInProgressState) - .nextCommitments[ids[1]] = part1.commitment; + .nextCommitments[ids[1]] = part1.commitment; await expectMalicious([getReply(1, 3)]); // Start round for next tests by adding commitment from 3rd @@ -1362,8 +1297,8 @@ void main() { // Get commitment set from event final evs = await clients[1].getEvents(); - final commitments = (evs.last as SignatureNewRoundsEvent) - .rounds.first.commitments; + final commitments = + (evs.last as SignatureNewRoundsEvent).rounds.first.commitments; // Missing share await expectMalicious([getReply(1, 3)]); @@ -1371,7 +1306,8 @@ void main() { // Share unnecessary await expectMalicious([ getReply( - 1, 1, + 1, + 1, nonce: part1.nonces, commitments: commitments, ), @@ -1387,7 +1323,6 @@ void main() { signDetailsOverride: getSignDetails(0), ), ]); - }); test( @@ -1400,29 +1335,30 @@ void main() { ); Future doMalicious(int i) => expectInvalid( - () => ctx.api.submitSignatureReplies( - sid: clients[i].sid, - reqId: sigsDetails.id, - replies: [ - SignatureReply( - // Malicious due to wrong index - sigI: 4, - nextCommitment: doPart1(i, 0).commitment, + () => ctx.api.submitSignatureReplies( + sid: clients[i].sid, + reqId: sigsDetails.id, + replies: [ + SignatureReply( + // Malicious due to wrong index + sigI: 4, + nextCommitment: doPart1(i, 0).commitment, + ), + ], ), - ], - ), - ); + ); test("can process multiple rounds to success", () async { - final List> part1s = List.generate( - 10, (i) => i == 0 ? creatorPart1s : [null, null, null, null], + 10, + (i) => i == 0 ? creatorPart1s : [null, null, null, null], ); - final List> commitmentSets - = List.generate( - 10, (i) => [null, null, null, null], - ); + final List> commitmentSets = + List.generate( + 10, + (i) => [null, null, null, null], + ); void expectAndProcessRounds( int i, @@ -1449,7 +1385,8 @@ void main() { } Future expectAndProcessNewRoundsEvent( - int i, List sigIs, + int i, + List sigIs, ) async { final evs = await clients[i].getEvents(); expect(evs, hasLength(1)); @@ -1466,15 +1403,17 @@ void main() { return ctx.api.submitSignatureReplies( sid: clients[i].sid, reqId: sigsDetails.id, - replies: sigIs.map( - (sigI) => getReply( - i, - sigI, - commitment: part1s[i][sigI]!.commitment, - nonce: thisPart1s[sigI]?.nonces, - commitments: commitmentSets[i][sigI], - ), - ).toList(), + replies: sigIs + .map( + (sigI) => getReply( + i, + sigI, + commitment: part1s[i][sigI]!.commitment, + nonce: thisPart1s[sigI]?.nonces, + commitments: commitmentSets[i][sigI], + ), + ) + .toList(), ); } @@ -1491,18 +1430,16 @@ void main() { // 2: p=[0,1,2] // 3: r=[0,1,2] p=[] for (int i = 1; i < 3; i++) { - - final resp = await submit(i, [0,1,2,3]); + final resp = await submit(i, [0, 1, 2, 3]); if (i == 2) { - expectNewRoundsResponse(2, resp, [0,1,3]); + expectNewRoundsResponse(2, resp, [0, 1, 3]); for (int j = 0; j < 2; j++) { - await expectAndProcessNewRoundsEvent(j, [0,1,3]); + await expectAndProcessNewRoundsEvent(j, [0, 1, 3]); } } else { expect(resp, null); } - } // Only has 0 left as rejector @@ -1514,17 +1451,17 @@ void main() { // 2: r=[0,1,2,3] p=[] // 3: r=[0ok,1ok,2] r=[0,1,3] p=[] for (int i = 0; i < 2; i++) { - expect(await submit(i, [0,1,3]), null); + expect(await submit(i, [0, 1, 3]), null); } // No more rejectors expect(reqState.rejectors, isEmpty); expectNewRoundsResponse( 3, - await submit(3, [0,1,2,3]), - [0,1,2,3], + await submit(3, [0, 1, 2, 3]), + [0, 1, 2, 3], ); for (int i = 0; i < 2; i++) { - await expectAndProcessNewRoundsEvent(i, [0,1,2,3]); + await expectAndProcessNewRoundsEvent(i, [0, 1, 2, 3]); } await expectAndProcessNewRoundsEvent(2, [2]); @@ -1539,14 +1476,15 @@ void main() { // 2: r=[0,1ok,2,3ok] r=[1,3,6,7] p=[] // 3: r=[0ok,1ok,2] r=[0,1ok,3ok] r=[1,3,8] p=[] for (final i in [1, 3]) { - await submit(i, [0,1,2,3]); + await submit(i, [0, 1, 2, 3]); } Future newRoundFor1And3(int newId, int sigI) async { expectNewRoundsResponse(newId, await submit(newId, [sigI]), [sigI]); - for (final i in [1,3]) { + for (final i in [1, 3]) { await expectAndProcessNewRoundsEvent(i, [sigI]); } } + await newRoundFor1And3(4, 0); await newRoundFor1And3(5, 1); await submit(6, [2]); @@ -1555,7 +1493,7 @@ void main() { await newRoundFor1And3(8, 3); // Malicious 2 has no effect - await expectInvalid(() => submit(2, [0,1,2,3])); + await expectInvalid(() => submit(2, [0, 1, 2, 3])); await ctx.expectNoEventsOrError(); // Complete 0 and 1 with successful share in 1nd round by 0, but do @@ -1564,11 +1502,11 @@ void main() { // 1: r=[0ok,1ok,2] r=[0ok,1ok,3ok] r=[1,3,5] DONE // 2: r=[0ok,1ok,2,3ok] r=[1,3,6,7] p=[] // 3: r=[0ok,1ok,2] r=[0,1ok,3ok] r=[1,3,8] p=[] - await submit(0, [0,1]); + await submit(0, [0, 1]); await ctx.expectNoEventsOrError(); // Give 6 malicious int total (5 more) without failure - Future.wait([5,6,7,8,9].map(doMalicious)); + Future.wait([5, 6, 7, 8, 9].map(doMalicious)); // Complete 2 and 3 in last round by last remaining good participants: // 0,1,3,4 @@ -1577,12 +1515,12 @@ void main() { // 1: r=[0ok,1ok,2] r=[0ok,1ok,3ok] r=[1,3,5] DONE // 2: r=[0ok,1ok,2,3ok] r=[1ok,3,6,7] r=[0ok,1ok,3ok,4ok] DONE // 3: r=[0ok,1ok,2] r=[0ok,1ok,3ok] r=[1,3,8] p=[0] DONE - for (final i in [0,1,3,4]) { - final resp = await submit(i, i == 0 ? [2, 3] : [0,1,2,3]); + for (final i in [0, 1, 3, 4]) { + final resp = await submit(i, i == 0 ? [2, 3] : [0, 1, 2, 3]); if (i == 4) { // New round for 2 expectNewRoundsResponse(4, resp, [2]); - for (final i2 in [0,1,3]) { + for (final i2 in [0, 1, 3]) { await expectAndProcessNewRoundsEvent(i2, [2]); } } else { @@ -1593,7 +1531,7 @@ void main() { // Finalise sig 2 with participants 0,1,3,4 and collect resulting // signatures late List sigs; - for (final i in [0,1,3,4]) { + for (final i in [0, 1, 3, 4]) { final resp = await submit(i, [2]); if (i == 4) { // Response has signatures @@ -1639,11 +1577,9 @@ void main() { // Request no longer exists after signatures have been made expectSigReqExists(false); - }); test("fails with too many malicious", () async { - await ctx.clearEvents(); // Complete signature 2 so that the max threshold is only 3 @@ -1664,15 +1600,11 @@ void main() { } await expectFailedReq(); - }); - }); - }); group(".shareSecretShare", () { - late List clients; late EncryptedKeyShare dummyShare; @@ -1687,39 +1619,37 @@ void main() { }); test("invalid request", () async { + // Invalid Session ID + await expectInvalid( + () => ctx.api.shareSecretShare( + sid: SessionID(), + groupKey: groupPublicKey, + encryptedSecrets: {ids.last: dummyShare}, + ), + ); - // Invalid Session ID - await expectInvalid( - () => ctx.api.shareSecretShare( - sid: SessionID(), - groupKey: groupPublicKey, - encryptedSecrets: { ids.last: dummyShare }, - ), - ); - - Future expectInvalidSecrets( - Map secrets, - ) => expectInvalid( - () => ctx.api.shareSecretShare( - sid: clients.first.sid, - groupKey: groupPublicKey, - encryptedSecrets: secrets, - ), - ); - - // Cannot be empty - await expectInvalidSecrets({}); + Future expectInvalidSecrets( + Map secrets, + ) => + expectInvalid( + () => ctx.api.shareSecretShare( + sid: clients.first.sid, + groupKey: groupPublicKey, + encryptedSecrets: secrets, + ), + ); - // Cannot send to self - await expectInvalidSecrets({ ids.first: dummyShare }); + // Cannot be empty + await expectInvalidSecrets({}); - // Identifiers must be in group - await expectInvalidSecrets({ Identifier.fromUint16(11): dummyShare }); + // Cannot send to self + await expectInvalidSecrets({ids.first: dummyShare}); + // Identifiers must be in group + await expectInvalidSecrets({Identifier.fromUint16(11): dummyShare}); }); test("success with ackKeyConstructed", () async { - void expectCompleted(ConstructedKeyEvent event, int who) { expect(event.participant, ids[who]); expect(event.constructedKey.obj.publicKey, groupPublicKey); @@ -1728,13 +1658,15 @@ void main() { Future sendTo( int from, - Iterable to, - { int? expectedCompleted, } - ) async { + Iterable to, { + int? expectedCompleted, + }) async { final events = await ctx.api.shareSecretShare( sid: clients[from].sid, groupKey: groupPublicKey, - encryptedSecrets: { for (final id in to) ids[id]: dummyShare, }, + encryptedSecrets: { + for (final id in to) ids[id]: dummyShare, + }, ); if (expectedCompleted == null) { expect(events, isEmpty); @@ -1744,15 +1676,14 @@ void main() { } } - Future sendToAll(int from, { int? expectedCompleted }) => sendTo( - from, - List.generate(10, (i) => i).where((id) => id != from), - expectedCompleted: expectedCompleted, - ); + Future sendToAll(int from, {int? expectedCompleted}) => sendTo( + from, + List.generate(10, (i) => i).where((id) => id != from), + expectedCompleted: expectedCompleted, + ); // First and second sends to everyone for (int from = 0; from < 2; from++) { - await sendToAll(from); // Expect events to logged in @@ -1765,7 +1696,6 @@ void main() { expect(ev.sender, ids[from]); expect(ev.groupKey, groupPublicKey); } - } // Others obtain both on login @@ -1799,7 +1729,7 @@ void main() { // 3rd gives shares to first and last. Returns ConstructedKeyEvent for // last. First receives SecretShareEvent. - await sendTo(2, {0,9}, expectedCompleted: 9); + await sendTo(2, {0, 9}, expectedCompleted: 9); final ev = await clients.first.getExpectOneEvent(); expect(ev.sender, ids[2]); expect(ev.groupKey, groupPublicKey); @@ -1807,9 +1737,7 @@ void main() { for (final client in ctx.clients.skip(1)) { await client.expectNoEvents(); } - }); - }); test(".ackKeyConstructed invalid request", () async { @@ -1837,18 +1765,16 @@ void main() { // Cannot do twice Future doMethod() => ctx.api.ackKeyConstructed( - sid: client.sid, - constructedKey: validSigned, - ); + sid: client.sid, + constructedKey: validSigned, + ); await doMethod(); await expectInvalid(doMethod); - }); test("can shutdown with logged in clients", () async { await Future.wait(List.generate(5, (i) => ctx.login(i))); await ctx.api.shutdown(); }); - }); } diff --git a/test/server/ring_buffer_test.dart b/test/server/ring_buffer_test.dart index 8c2a9e1..6e47851 100644 --- a/test/server/ring_buffer_test.dart +++ b/test/server/ring_buffer_test.dart @@ -2,15 +2,12 @@ import 'package:noosphere_roast_server/src/server/state/ring_buffer.dart'; import 'package:test/test.dart'; void main() { - group("RingBuffer", () { - test("must be a positive maxSize", () { expect(() => RingBuffer(-1), throwsArgumentError); }); group("given RingBuffer of 10 max", () { - late RingBuffer buffer; setUp(() => buffer = RingBuffer(10)); @@ -41,13 +38,9 @@ void main() { for (int i = 0; i < 11; i++) { buffer.add(i); } - expect(buffer.flushBuffer(), List.generate(10, (i) => i+1)); + expect(buffer.flushBuffer(), List.generate(10, (i) => i + 1)); expectFlushEmpty(); }); - }); - }); - } - diff --git a/test/sig_data.dart b/test/sig_data.dart index d1ac4e2..6f61f23 100644 --- a/test/sig_data.dart +++ b/test/sig_data.dart @@ -3,18 +3,18 @@ import 'package:noosphere_roast_server/noosphere_roast_server.dart'; import 'data.dart'; import 'test_keys.dart'; -ParticipantKeyInfo getParticipantKeyInfo({ int i = 0, int? tweak }) { +ParticipantKeyInfo getParticipantKeyInfo({int i = 0, int? tweak}) { final key = ParticipantKeyInfo.fromHex(keyInfoHex[i]); return tweak == null ? key : key.tweak(Uint8List(32)..last = tweak)!; } -AggregateKeyInfo getAggregateKeyInfo({ int? tweak }) - => getParticipantKeyInfo(i: 0, tweak: tweak).aggregate; +AggregateKeyInfo getAggregateKeyInfo({int? tweak}) => + getParticipantKeyInfo(i: 0, tweak: tweak).aggregate; List generateNewKey(int threshold) { - final part1s = List.generate( - 10, (i) => DkgPart1(identifier: ids[i], threshold: threshold, n: 10), + 10, + (i) => DkgPart1(identifier: ids[i], threshold: threshold, n: 10), ); final commitmentSet = DkgCommitmentSet( @@ -31,9 +31,10 @@ List generateNewKey(int threshold) { ); final shares = List.generate( - 10, (i) => { + 10, + (i) => { for (int j = 0; j < 10; j++) - if (j != i) ids[j] : part2s[j].sharesToGive[ids[i]]!, + if (j != i) ids[j]: part2s[j].sharesToGive[ids[i]]!, }, ); @@ -46,37 +47,36 @@ List generateNewKey(int threshold) { receivedShares: shares[i], ).participantInfo, ); - } -SignDetails getSignDetails([ int? tweak ]) => SignDetails.keySpend( - message: Uint8List(32)..last = tweak ?? 0, -); +SignDetails getSignDetails([int? tweak]) => SignDetails.keySpend( + message: Uint8List(32)..last = tweak ?? 0, + ); -SingleSignatureDetails getSingleSigDetails({ int? tweak }) - => SingleSignatureDetails( - signDetails: getSignDetails(tweak), - groupKey: getAggregateKeyInfo(tweak: tweak).groupKey, - hdDerivation: [], - ); +SingleSignatureDetails getSingleSigDetails({int? tweak}) => + SingleSignatureDetails( + signDetails: getSignDetails(tweak), + groupKey: getAggregateKeyInfo(tweak: tweak).groupKey, + hdDerivation: [], + ); SignaturesRequestDetails getSignaturesDetails({ List singleSigTweaks = const [0], SignatureMetadata? metadata, Expiry? expiry, -}) => SignaturesRequestDetails( - requiredSigs: [ - for (final tweak in singleSigTweaks) getSingleSigDetails(tweak: tweak), - ], - expiry: expiry ?? futureExpiry, -); - -SignPart1 getSignPart1({ int i = 0, int? tweak }) => SignPart1( - privateShare: getParticipantKeyInfo(i: i, tweak: tweak).private.share, -); +}) => + SignaturesRequestDetails( + requiredSigs: [ + for (final tweak in singleSigTweaks) getSingleSigDetails(tweak: tweak), + ], + expiry: expiry ?? futureExpiry, + ); + +SignPart1 getSignPart1({int i = 0, int? tweak}) => SignPart1( + privateShare: getParticipantKeyInfo(i: i, tweak: tweak).private.share, + ); SignPart2 dummyPart2() { - final part1s = List.generate(2, (i) => getSignPart1(i: i)); return SignPart2( @@ -84,9 +84,8 @@ SignPart2 dummyPart2() { details: getSignDetails(), ourNonces: part1s.first.nonces, commitments: SigningCommitmentSet( - { for (int i = 0; i < 2; i++) ids[i]: part1s[i].commitment }, + {for (int i = 0; i < 2; i++) ids[i]: part1s[i].commitment}, ), info: getParticipantKeyInfo().signing, ); - } diff --git a/test/test_keys.dart b/test/test_keys.dart index 836e45e..99a1062 100644 --- a/test/test_keys.dart +++ b/test/test_keys.dart @@ -15,31 +15,32 @@ final keyInfoHex = [ "0330a4892d9d1e85857df6a04e7939a8bd29ab6e3c26bbe659e61ac20eb839294302000a00000000000000000000000000000000000000000000000000000000000000000102043937397e0d06d85ec20f3b763e9d7132aa8ad91791ef8508f413ed0ea750790000000000000000000000000000000000000000000000000000000000000002032051264ac6806579b434e45b9159018eee072dd2cc57d272fa632afd81ca8a280000000000000000000000000000000000000000000000000000000000000003032cda5f5f18b9c6fc1f118cb58d65facbde8da456c4ec5fbad1bea486615bdbab00000000000000000000000000000000000000000000000000000000000000040237813be560290657bf82fbfbc99d2d1079640bb1e6b916625e01f165fd8fd7c2000000000000000000000000000000000000000000000000000000000000000503f63f0863685f7541e8c8468559ddad29865f8f0211c2294fdc877306dc7c7dcb000000000000000000000000000000000000000000000000000000000000000603e13ba002c61ed2a9628db23db6fe3b8536833433f9d13acdabd3271b4bf950bf00000000000000000000000000000000000000000000000000000000000000070388d27cd0b957503def7ebe1c9c0a21f856ee0fdbb17dded2d2043bd488c1eb330000000000000000000000000000000000000000000000000000000000000008038077b89418331dcd40568adda68173f9c7829467c4d4422e2d86354677d0b431000000000000000000000000000000000000000000000000000000000000000902beb254eff1a4d59ea30da76b2e093aef33e24a749afe3f4e18b9a52f3bd188cc000000000000000000000000000000000000000000000000000000000000000a02ca0db26ef4a4dbba77eceaec03e5a81121933f38e591791668a169527614a14a000000000000000000000000000000000000000000000000000000000000000a99f83f0443e005ee7776b64357da63a8507673ad20fdc968f6bd35171b82e3ee", ]; -final groupPublicKeyHex - = "0330a4892d9d1e85857df6a04e7939a8bd29ab6e3c26bbe659e61ac20eb8392943"; +final groupPublicKeyHex = + "0330a4892d9d1e85857df6a04e7939a8bd29ab6e3c26bbe659e61ac20eb8392943"; final groupPublicKey = cl.ECCompressedPublicKey.fromHex(groupPublicKeyHex); SignedDkgAck getDkgAck( int i, bool accepted, { - cl.ECCompressedPublicKey? groupKey, - } -) => SignedDkgAck( - signer: ids[i], - signed: Signed.sign( - obj: DkgAck(groupKey: groupKey ?? groupPublicKey, accepted: accepted), - key: getPrivkey(i), - ), -); - -InMemoryClientStorage storeWithKeyAndAcks(int i, Set acks) - => InMemoryClientStorage()..addOrReplaceFrostKey( - acks.fold( - FrostKeyWithDetails( - keyInfo: ParticipantKeyInfo.fromHex(keyInfoHex[i]), - name: "123", - description: "Desc", + cl.ECCompressedPublicKey? groupKey, +}) => + SignedDkgAck( + signer: ids[i], + signed: Signed.sign( + obj: DkgAck(groupKey: groupKey ?? groupPublicKey, accepted: accepted), + key: getPrivkey(i), ), - (details, ack) => details.addOrReplaceAck(ack), - ), - ); + ); + +InMemoryClientStorage storeWithKeyAndAcks(int i, Set acks) => + InMemoryClientStorage() + ..addOrReplaceFrostKey( + acks.fold( + FrostKeyWithDetails( + keyInfo: ParticipantKeyInfo.fromHex(keyInfoHex[i]), + name: "123", + description: "Desc", + ), + (details, ack) => details.addOrReplaceAck(ack), + ), + ); From b26a60959770ce60f902b2068f5bdb28629a2e19 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Thu, 18 Jun 2026 14:59:40 +0200 Subject: [PATCH 17/25] ran dart fix --apply --- test/client_test.dart | 6 +++--- test/grpc_test.dart | 2 +- test/server/api_handler_test.dart | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/client_test.dart b/test/client_test.dart index 7df5bda..a92b52e 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -1110,7 +1110,7 @@ void main() { await loginWithOwnAck(0), DkgAckRequestEvent( { - DkgAckRequest(ids: {badId}, groupPublicKey: groupPublicKey) + DkgAckRequest(ids: {badId}, groupPublicKey: groupPublicKey), }, ), ); @@ -1588,7 +1588,7 @@ void main() { ...badNewRounds.map((nre) => [nre]), // Missing request [ - SignatureNewRoundsEvent(reqId: missingReqId, rounds: [validRound]) + SignatureNewRoundsEvent(reqId: missingReqId, rounds: [validRound]), ], // Duplicate request [validEv, validEv], @@ -1815,7 +1815,7 @@ void main() { // Only one sig, or incorrect sig for second final sigs in [ [validFirstSig], - [validFirstSig, validFirstSig] + [validFirstSig, validFirstSig], ]) { await expectBadEventRelogin( 0, diff --git a/test/grpc_test.dart b/test/grpc_test.dart index 52826bb..eaa856c 100644 --- a/test/grpc_test.dart +++ b/test/grpc_test.dart @@ -267,7 +267,7 @@ void main() { tcs.take(2).map((tc) => tc.client.shareKeySecret(groupKey)), ); await Future.wait( - tcs.take(9).map((tc) => tc.waitForKeyConstructed())); + tcs.take(9).map((tc) => tc.waitForKeyConstructed()),); // Last logs in and obtains key await reloginLast(); diff --git a/test/server/api_handler_test.dart b/test/server/api_handler_test.dart index 825a102..8abe2c6 100644 --- a/test/server/api_handler_test.dart +++ b/test/server/api_handler_test.dart @@ -445,7 +445,7 @@ void main() { test("invalid request", () async { await expectInvalid( - () => ctx.api.rejectDkg(sid: SessionID(), name: "123")); + () => ctx.api.rejectDkg(sid: SessionID(), name: "123"),); }); test("success", () async { @@ -849,7 +849,7 @@ void main() { () => ctx.api.requestDkgAcks( sid: SessionID(), requests: { - getReq({0}) + getReq({0}), }, ), ); @@ -869,7 +869,7 @@ void main() { () => ctx.api.requestDkgAcks( sid: ctx.clients.first.sid, requests: { - getReq({0}) + getReq({0}), }, ), ); @@ -917,7 +917,7 @@ void main() { // Request only what the server has requests: { getReq({1, 2}), - getReq({1}, altKey) + getReq({1}, altKey), }, ); expect(haveAcks, toHave); From bbbecc3068b2504454fd59c7d5fe50d2876938de Mon Sep 17 00:00:00 2001 From: peerchemist Date: Thu, 18 Jun 2026 15:40:09 +0200 Subject: [PATCH 18/25] Inject server logger dependencies --- bin/grpc_server.dart | 19 +++-- lib/src/grpc.dart | 20 +++-- lib/src/logging.dart | 31 +------- lib/src/rest.dart | 77 +++++++++++--------- lib/src/server/api_handler.dart | 54 ++++++++------ lib/src/server/state/state.dart | 9 ++- lib/src/server/synchronized_api_handler.dart | 5 +- test/rest_test.dart | 3 + 8 files changed, 113 insertions(+), 105 deletions(-) diff --git a/bin/grpc_server.dart b/bin/grpc_server.dart index 8b7bcfd..04e60e2 100644 --- a/bin/grpc_server.dart +++ b/bin/grpc_server.dart @@ -49,7 +49,7 @@ void main(List args) async { defaultsTo: "info", ); final argResults = argParser.parse(args); - configureNoosphereRoastServerLogging( + final logger = createNoosphereRoastServerLogger( level: _logLevels[argResults.option("log-level")]!, ); final configFile = argResults.option("config")!; @@ -60,16 +60,19 @@ void main(List args) async { await loadFrosty(); final config = GrpcConfig.fromYaml(configString); - noosphereRoastServerLogger.i("Loaded config from $configFile"); - noosphereRoastServerLogger.i( + logger.i("Loaded config from $configFile"); + logger.i( "Group fingerprint is ${bytesToHex(config.server.group.fingerprint)}", ); - final apiHandler = SynchronizedServerApiHandler(config: config.server); + final apiHandler = SynchronizedServerApiHandler( + config: config.server, + logger: logger, + ); final service = FrostNoosphereService(api: apiHandler); final grpcServer = service.createServer(); await grpcServer.serve(port: config.port); - noosphereRoastServerLogger.i("gRPC server listening on port ${config.port}"); + logger.i("gRPC server listening on port ${config.port}"); HttpServer? restServer; if (restPort != null) { @@ -81,7 +84,7 @@ void main(List args) async { ); final restAddress = argResults.option("rest-address")!; restServer = await restService.serve(address: restAddress, port: restPort); - noosphereRoastServerLogger.i( + logger.i( "REST/WebSocket server listening on $restAddress:${restServer.port}", ); } @@ -93,7 +96,7 @@ void main(List args) async { for (final signal in [ProcessSignal.sigint, ProcessSignal.sigterm]) { signal.watch().listen((sig) { if (termCompleter.isCompleted) { - noosphereRoastServerLogger.w("Exiting immediately"); + logger.w("Exiting immediately"); exit(0); } termCompleter.complete(sig); @@ -101,7 +104,7 @@ void main(List args) async { } final signal = await termCompleter.future; - noosphereRoastServerLogger.i( + logger.i( "Caught ${signal.name}. Shutting down server.", ); diff --git a/lib/src/grpc.dart b/lib/src/grpc.dart index cefe8ba..743fa55 100644 --- a/lib/src/grpc.dart +++ b/lib/src/grpc.dart @@ -18,8 +18,12 @@ pb.Bytes _returnWritable(cl.Writable writable) => pb.Bytes( class FrostNoosphereService extends pb.NoosphereServiceBase { final ServerApiHandler api; + final Logger logger; - FrostNoosphereService({required this.api}); + FrostNoosphereService({ + required this.api, + Logger? logger, + }) : logger = logger ?? api.logger; grpc.Server createServer() => grpc.Server.create(services: [this]); @@ -29,9 +33,9 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { StackTrace? stackTrace, ]) { if (e is InvalidRequest) { - noosphereRoastServerLogger.w("gRPC $method rejected: ${e.message}"); + logger.w("gRPC $method rejected: ${e.message}"); } else { - noosphereRoastServerLogger.e( + logger.e( "gRPC $method failed", error: e, stackTrace: stackTrace, @@ -44,10 +48,10 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { String method, Future Function() f, ) async { - noosphereRoastServerLogger.d("gRPC $method received"); + logger.d("gRPC $method received"); try { final result = await f(); - noosphereRoastServerLogger.d("gRPC $method completed"); + logger.d("gRPC $method completed"); return result; } on Exception catch (e, stackTrace) { throw _wrapException(method, e, stackTrace); @@ -108,7 +112,7 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { throw _wrapException("fetchEventStream", e, stackTrace); } - noosphereRoastServerLogger.d( + logger.d( "gRPC fetchEventStream opened for participant ${session.participantId}", ); @@ -117,7 +121,7 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { // Without calling this, the grpc stream may hang and never close. final controller = StreamController( onCancel: () { - noosphereRoastServerLogger.d( + logger.d( "gRPC fetchEventStream canceled for participant " "${session.participantId}", ); @@ -127,7 +131,7 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { // When upstream stream is done, cancel this one controller.addStream(session.eventController.stream).then( (_) { - noosphereRoastServerLogger.d( + logger.d( "gRPC fetchEventStream closed for participant " "${session.participantId}", ); diff --git a/lib/src/logging.dart b/lib/src/logging.dart index 040a25c..e7e92ba 100644 --- a/lib/src/logging.dart +++ b/lib/src/logging.dart @@ -3,7 +3,9 @@ export 'package:logger/logger.dart' import 'package:logger/logger.dart'; -Logger _defaultNoosphereRoastServerLogger({ +/// Creates the default logger used by server components when no logger is +/// provided. +Logger createNoosphereRoastServerLogger({ Level level = Level.info, LogFilter? filter, LogPrinter? printer, @@ -15,30 +17,3 @@ Logger _defaultNoosphereRoastServerLogger({ output: output, level: level, ); - -/// Logger used by this package. -/// -/// Replace it with [configureNoosphereRoastServerLogging] when embedding the -/// package in an application that already has logging configured. -Logger noosphereRoastServerLogger = _defaultNoosphereRoastServerLogger(); - -/// Configures the logger used by this package. -/// -/// Pass [logger] to take full control, or pass individual logger components to -/// keep the package defaults while changing the level, filter, printer, or -/// output. -void configureNoosphereRoastServerLogging({ - Logger? logger, - Level level = Level.info, - LogFilter? filter, - LogPrinter? printer, - LogOutput? output, -}) { - noosphereRoastServerLogger = logger ?? - _defaultNoosphereRoastServerLogger( - level: level, - filter: filter, - printer: printer, - output: output, - ); -} diff --git a/lib/src/rest.dart b/lib/src/rest.dart index ecc80b8..dc9d4fa 100644 --- a/lib/src/rest.dart +++ b/lib/src/rest.dart @@ -56,11 +56,13 @@ Middleware restSseCors({String allowOrigin = '*'}) => class RestWebSocketNoosphereService { final ServerApiHandler api; final String? allowOrigin; + final Logger logger; RestWebSocketNoosphereService({ required this.api, this.allowOrigin = '*', - }); + Logger? logger, + }) : logger = logger ?? api.logger; Handler get handler { final router = Router() @@ -96,7 +98,8 @@ class RestWebSocketNoosphereService { }) => shelf_io.serve(handler, address, port); - Future _login(Request request) => _handleJson(request, () async { + Future _login(Request request) => + _handleJson(request, logger, () async { final json = await _readJson(request); final resp = await api.login( groupFingerprint: _fieldBytes(json, 'groupFingerprint'), @@ -110,6 +113,7 @@ class RestWebSocketNoosphereService { Future _respondToChallenge(Request request) => _handleJson( request, + logger, () async { final json = await _readJson(request); final resp = await api.respondToChallenge( @@ -123,14 +127,14 @@ class RestWebSocketNoosphereService { ); Future _extendSession(Request request) => - _handleJson(request, () async { + _handleJson(request, logger, () async { final json = await _readJson(request); final resp = await api.extendSession(_sid(_fieldBytes(json, 'sid'))); return _bytesResponse(resp.toBytes()); }); Future _requestNewDkg(Request request) => - _handleEmpty(request, () async { + _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.requestNewDkg( sid: _sid(_fieldBytes(json, 'sid')), @@ -145,7 +149,7 @@ class RestWebSocketNoosphereService { }); Future _rejectDkg(Request request) => - _handleEmpty(request, () async { + _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.rejectDkg( sid: _sid(_fieldBytes(json, 'sid')), @@ -154,7 +158,7 @@ class RestWebSocketNoosphereService { }); Future _submitDkgCommitment(Request request) => - _handleEmpty(request, () async { + _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.submitDkgCommitment( sid: _sid(_fieldBytes(json, 'sid')), @@ -166,7 +170,7 @@ class RestWebSocketNoosphereService { }); Future _submitDkgRound2(Request request) => - _handleEmpty(request, () async { + _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.submitDkgRound2( sid: _sid(_fieldBytes(json, 'sid')), @@ -185,7 +189,7 @@ class RestWebSocketNoosphereService { }); Future _sendDkgAcks(Request request) => - _handleEmpty(request, () async { + _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.sendDkgAcks( sid: _sid(_fieldBytes(json, 'sid')), @@ -196,7 +200,7 @@ class RestWebSocketNoosphereService { }); Future _requestDkgAcks(Request request) => - _handleJson(request, () async { + _handleJson(request, logger, () async { final json = await _readJson(request); final resp = await api.requestDkgAcks( sid: _sid(_fieldBytes(json, 'sid')), @@ -208,7 +212,7 @@ class RestWebSocketNoosphereService { }); Future _requestSignatures(Request request) => - _handleEmpty(request, () async { + _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.requestSignatures( sid: _sid(_fieldBytes(json, 'sid')), @@ -230,7 +234,7 @@ class RestWebSocketNoosphereService { }); Future _rejectSignaturesRequest(Request request) => - _handleEmpty(request, () async { + _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.rejectSignaturesRequest( sid: _sid(_fieldBytes(json, 'sid')), @@ -239,7 +243,7 @@ class RestWebSocketNoosphereService { }); Future _submitSignatureReplies(Request request) => - _handleJson(request, () async { + _handleJson(request, logger, () async { final json = await _readJson(request); final resp = await api.submitSignatureReplies( sid: _sid(_fieldBytes(json, 'sid')), @@ -260,7 +264,7 @@ class RestWebSocketNoosphereService { }); Future _shareSecretShare(Request request) => - _handleJson(request, () async { + _handleJson(request, logger, () async { final json = await _readJson(request); final resp = await api.shareSecretShare( sid: _sid(_fieldBytes(json, 'sid')), @@ -277,7 +281,7 @@ class RestWebSocketNoosphereService { }); Future _ackKeyConstructed(Request request) => - _handleEmpty(request, () async { + _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.ackKeyConstructed( sid: _sid(_fieldBytes(json, 'sid')), @@ -290,19 +294,19 @@ class RestWebSocketNoosphereService { FutureOr _fetchEventWebSocket(Request request, String sid) { final description = _requestDescription(request); - noosphereRoastServerLogger.d("REST $description received"); + logger.d("REST $description received"); try { final session = api.getSession(_sid(_decodeBytes(sid))); final handler = webSocketHandler( (WebSocketChannel webSocket, String? _) { - noosphereRoastServerLogger.d("REST $description opened"); + logger.d("REST $description opened"); final eventSubscription = session.eventController.stream.listen( - (event) => webSocket.sink.add(_webSocketEvent(event)), + (event) => webSocket.sink.add(_webSocketEvent(event, logger)), onDone: () { unawaited(webSocket.sink.close(WebSocketStatus.normalClosure)); }, onError: (Object e, StackTrace stackTrace) { - noosphereRoastServerLogger.e( + logger.e( "REST $description event stream failed", error: e, stackTrace: stackTrace, @@ -317,10 +321,10 @@ class RestWebSocketNoosphereService { (_) {}, onDone: () { unawaited(eventSubscription.cancel()); - noosphereRoastServerLogger.d("REST $description closed"); + logger.d("REST $description closed"); }, onError: (Object e) { - noosphereRoastServerLogger.w( + logger.w( "REST $description socket failed: $e", ); unawaited(eventSubscription.cancel()); @@ -332,19 +336,19 @@ class RestWebSocketNoosphereService { ); return handler(request); } on InvalidRequest catch (e) { - noosphereRoastServerLogger.w( + logger.w( "REST $description rejected: ${e.message}", ); return _jsonResponse({'error': e.message}, status: 400); } on FormatException catch (e) { - noosphereRoastServerLogger.w( + logger.w( "REST $description rejected: ${e.message}", ); return _jsonResponse({'error': e.message}, status: 400); } on HijackException { rethrow; } on Exception catch (e, stackTrace) { - noosphereRoastServerLogger.e( + logger.e( "REST $description failed", error: e, stackTrace: stackTrace, @@ -365,31 +369,33 @@ class RestSseNoosphereService extends RestWebSocketNoosphereService { RestSseNoosphereService({ required super.api, super.allowOrigin, + super.logger, }); } Future _handleEmpty( Request request, + Logger logger, Future Function() action, ) async { final description = _requestDescription(request); - noosphereRoastServerLogger.d("REST $description received"); + logger.d("REST $description received"); try { await action(); - noosphereRoastServerLogger.d("REST $description completed"); + logger.d("REST $description completed"); return _jsonResponse({}); } on InvalidRequest catch (e) { - noosphereRoastServerLogger.w( + logger.w( "REST $description rejected: ${e.message}", ); return _jsonResponse({'error': e.message}, status: 400); } on FormatException catch (e) { - noosphereRoastServerLogger.w( + logger.w( "REST $description rejected: ${e.message}", ); return _jsonResponse({'error': e.message}, status: 400); } on Exception catch (e, stackTrace) { - noosphereRoastServerLogger.e( + logger.e( "REST $description failed", error: e, stackTrace: stackTrace, @@ -400,26 +406,27 @@ Future _handleEmpty( Future _handleJson( Request request, + Logger logger, Future Function() action, ) async { final description = _requestDescription(request); - noosphereRoastServerLogger.d("REST $description received"); + logger.d("REST $description received"); try { final response = await action(); - noosphereRoastServerLogger.d("REST $description completed"); + logger.d("REST $description completed"); return response; } on InvalidRequest catch (e) { - noosphereRoastServerLogger.w( + logger.w( "REST $description rejected: ${e.message}", ); return _jsonResponse({'error': e.message}, status: 400); } on FormatException catch (e) { - noosphereRoastServerLogger.w( + logger.w( "REST $description rejected: ${e.message}", ); return _jsonResponse({'error': e.message}, status: 400); } on Exception catch (e, stackTrace) { - noosphereRoastServerLogger.e( + logger.e( "REST $description failed", error: e, stackTrace: stackTrace, @@ -498,9 +505,9 @@ List _fieldStringList(Map json, String name) { }).toList(); } -String _webSocketEvent(Event event) { +String _webSocketEvent(Event event, Logger logger) { final type = _eventType(event); - noosphereRoastServerLogger.d("REST WebSocket sent $type"); + logger.d("REST WebSocket sent $type"); return jsonEncode({ 'type': type, 'data': _encodeBytes(event.toBytes()), diff --git a/lib/src/server/api_handler.dart b/lib/src/server/api_handler.dart index 173f48c..06e1863 100644 --- a/lib/src/server/api_handler.dart +++ b/lib/src/server/api_handler.dart @@ -23,14 +23,27 @@ class ServerApiHandler implements ApiRequestInterface { final ServerConfig config; final ServerState state; + final Logger logger; final DateTime startTime = DateTime.now(); - /// Creates a backend API handler with the [config]. A blank [state] will be - /// created if not provided. + /// Creates a backend API handler with the [config]. + /// + /// A blank [state] and default [logger] will be created if not provided. ServerApiHandler({ + required ServerConfig config, + ServerState? state, + Logger? logger, + }) : this._( + config: config, + state: state, + logger: logger ?? createNoosphereRoastServerLogger(), + ); + + ServerApiHandler._({ required this.config, + required this.logger, ServerState? state, - }) : state = state ?? ServerState(); + }) : state = state ?? ServerState(logger: logger); int get _participantN => config.group.participants.length; @@ -89,7 +102,7 @@ class ServerApiHandler implements ApiRequestInterface { expiry: expiry, ); - noosphereRoastServerLogger.i( + logger.i( "Issued auth challenge for participant $participantId", ); @@ -162,7 +175,7 @@ class ServerApiHandler implements ApiRequestInterface { }); } - noosphereRoastServerLogger.i("Participant logged in: $pid"); + logger.i("Participant logged in: $pid"); return LoginCompleteResponse( id: sessionId, @@ -278,7 +291,7 @@ class ServerApiHandler implements ApiRequestInterface { commitments: commitments, ); - noosphereRoastServerLogger.i( + logger.i( "DKG requested: name=${details.name} creator=${session.participantId} " "threshold=${details.threshold}", ); @@ -291,7 +304,7 @@ class ServerApiHandler implements ApiRequestInterface { Future rejectDkg({required SessionID sid, required String name}) async { final participantId = getSession(sid).participantId; if (state.nameToDkg.remove(name) != null) { - noosphereRoastServerLogger.i( + logger.i( "DKG rejected: name=$name participant=$participantId", ); @@ -329,7 +342,7 @@ class ServerApiHandler implements ApiRequestInterface { dkg.round = DkgRound2State( expectedHash: dkg.details.obj.hashWithCommitments(commitmentSet), ); - noosphereRoastServerLogger.i( + logger.i( "DKG advanced to round 2: name=$name commitments=${commitments.length}", ); } @@ -391,7 +404,7 @@ class ServerApiHandler implements ApiRequestInterface { if (round.participantsProvided.length == _participantN - 1) { // Remove DKG state.nameToDkg.remove(name); - noosphereRoastServerLogger.i("DKG completed: name=$name"); + logger.i("DKG completed: name=$name"); // No details of the key are stored on the server as only the participants // can generate the public information at this point. } else { @@ -437,8 +450,7 @@ class ServerApiHandler implements ApiRequestInterface { // Do not send events if there are no new ACKs if (newAcks.isEmpty) return; - noosphereRoastServerLogger - .i("DKG acknowledgements received: ${newAcks.length}"); + logger.i("DKG acknowledgements received: ${newAcks.length}"); // Send ACKs to participants, ensuring that their own ACKs aren't sent // Do not send to calling participant @@ -508,7 +520,7 @@ class ServerApiHandler implements ApiRequestInterface { } if (need.isNotEmpty) { - noosphereRoastServerLogger.d( + logger.d( "Requested missing DKG acknowledgements: ${need.length}", ); @@ -585,7 +597,7 @@ class ServerApiHandler implements ApiRequestInterface { sid, ); - noosphereRoastServerLogger.i( + logger.i( "Signatures requested: id=${details.id.toHex()} creator=$pid " "signatures=$numSigs", ); @@ -603,7 +615,7 @@ class ServerApiHandler implements ApiRequestInterface { if (available < maxThreshold) { // Cannot sign one of the signatures as threshold is too high final id = sigReqState.details.obj.id; - noosphereRoastServerLogger.w( + logger.w( "Signatures request failed: id=${id.toHex()} available=$available " "required=$maxThreshold", ); @@ -628,7 +640,7 @@ class ServerApiHandler implements ApiRequestInterface { if (sigReq.malicious.contains(pid)) return; sigReq.rejectors.add(pid); - noosphereRoastServerLogger.i( + logger.i( "Signatures request rejected: id=${reqId.toHex()} participant=$pid", ); _checkSigReqFail(sigReq); @@ -650,7 +662,7 @@ class ServerApiHandler implements ApiRequestInterface { void throwMalicious(InvalidRequest exp) { sigReq.malicious.add(pid); - noosphereRoastServerLogger.w( + logger.w( "Participant marked malicious for signatures request: " "id=${reqId.toHex()} participant=$pid reason=${exp.message}", ); @@ -811,7 +823,7 @@ class ServerApiHandler implements ApiRequestInterface { sid, ); - noosphereRoastServerLogger.i( + logger.i( "Signatures request completed: id=${reqId.toHex()} " "signatures=${signatures.length}", ); @@ -822,7 +834,7 @@ class ServerApiHandler implements ApiRequestInterface { // If there are any new rounds, return them and send events to round // participants if (newRounds.isNotEmpty) { - noosphereRoastServerLogger.d( + logger.d( "Signature rounds started: id=${reqId.toHex()} " "participants=${newRounds.length}", ); @@ -880,7 +892,7 @@ class ServerApiHandler implements ApiRequestInterface { } } - noosphereRoastServerLogger.i( + logger.i( "Secret shares received: sender=$pid receivers=${encryptedSecrets.length} " "new=$addedShares", ); @@ -920,14 +932,14 @@ class ServerApiHandler implements ApiRequestInterface { // Send event to other participants state.sendEventToOthers(event, sid); - noosphereRoastServerLogger.i( + logger.i( "Constructed key acknowledged: participant=$pid", ); } /// Closes all client session streams Future shutdown() { - noosphereRoastServerLogger.i( + logger.i( "Shutting down API handler: sessions=${state.clientSessions.values.length}", ); return Future.wait( diff --git a/lib/src/server/state/state.dart b/lib/src/server/state/state.dart index d82fd67..4a429d1 100644 --- a/lib/src/server/state/state.dart +++ b/lib/src/server/state/state.dart @@ -46,6 +46,7 @@ class CompletedSignatures implements Expirable { } class ServerState { + final Logger logger; final challenges = ExpirableMap(); late final ExpirableMap clientSessions; final participantToSession = ExpirableMap(); @@ -60,14 +61,16 @@ class ServerState { /// participants final Map secretShares = {}; - ServerState() { + ServerState({ + Logger? logger, + }) : logger = logger ?? createNoosphereRoastServerLogger() { clientSessions = ExpirableMap( onExpired: (_, session) => onEndSession(session), ); } void onEndSession(ClientSession session) { - noosphereRoastServerLogger.i( + logger.i( "Participant session ended: ${session.participantId}", ); @@ -100,7 +103,7 @@ class ServerState { final recipients = sessions .where((session) => !exclude.contains(session.sessionID)) .toList(); - noosphereRoastServerLogger.d( + logger.d( "Broadcasting ${e.runtimeType} to ${recipients.length}/" "${sessions.length} sessions", ); diff --git a/lib/src/server/synchronized_api_handler.dart b/lib/src/server/synchronized_api_handler.dart index a96ebbb..b5a9425 100644 --- a/lib/src/server/synchronized_api_handler.dart +++ b/lib/src/server/synchronized_api_handler.dart @@ -26,14 +26,15 @@ class _ApiCallQueue { /// A [ServerApiHandler] that serializes state-mutating API calls. /// /// Use one shared instance of this class when exposing the same coordinator -/// through multiple transports, such as gRPC for desktop clients and REST/WebSocket -/// for web clients. +/// through multiple transports, such as gRPC for desktop clients and +/// REST/WebSocket for web clients. class SynchronizedServerApiHandler extends ServerApiHandler { final _queue = _ApiCallQueue(); SynchronizedServerApiHandler({ required super.config, super.state, + super.logger, }); @override diff --git a/test/rest_test.dart b/test/rest_test.dart index 87218ef..200e4f6 100644 --- a/test/rest_test.dart +++ b/test/rest_test.dart @@ -76,6 +76,9 @@ class _RestTestApi implements ServerApiHandler { final expiry = Expiry(Duration(minutes: 5)); final sessions = {}; + @override + final logger = createNoosphereRoastServerLogger(); + @override Future extendSession(SessionID sid) async { if (!sessions.containsKey(sid)) throw InvalidRequest.noSession(); From 63e04a09bf309f2a75bc918a8edf3052e5718035 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Fri, 19 Jun 2026 18:57:13 +0200 Subject: [PATCH 19/25] Require logger for server state --- lib/src/server/state/state.dart | 4 ++-- test/rest_test.dart | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/server/state/state.dart b/lib/src/server/state/state.dart index 4a429d1..2bc29be 100644 --- a/lib/src/server/state/state.dart +++ b/lib/src/server/state/state.dart @@ -62,8 +62,8 @@ class ServerState { final Map secretShares = {}; ServerState({ - Logger? logger, - }) : logger = logger ?? createNoosphereRoastServerLogger() { + required this.logger, + }) { clientSessions = ExpirableMap( onExpired: (_, session) => onEndSession(session), ); diff --git a/test/rest_test.dart b/test/rest_test.dart index 200e4f6..730129f 100644 --- a/test/rest_test.dart +++ b/test/rest_test.dart @@ -198,7 +198,7 @@ void main() { }); test('streams websocket events sent through server state fanout', () async { - final state = ServerState(); + final state = ServerState(logger: api.logger); final creatorSid = _sid(1); final receiverSid = _sid(2); From 7b42d17502b7e484dc8bdaf1a36d997b4dc57884 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Fri, 19 Jun 2026 18:59:28 +0200 Subject: [PATCH 20/25] Avoid redundant session list materialization --- lib/src/server/state/state.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/src/server/state/state.dart b/lib/src/server/state/state.dart index 2bc29be..8996629 100644 --- a/lib/src/server/state/state.dart +++ b/lib/src/server/state/state.dart @@ -99,13 +99,12 @@ class ServerState { ); void sendEventToAll(Event e, {List exclude = const []}) { - final sessions = clientSessions.values.toList(); - final recipients = sessions + final recipients = clientSessions.values .where((session) => !exclude.contains(session.sessionID)) .toList(); logger.d( "Broadcasting ${e.runtimeType} to ${recipients.length}/" - "${sessions.length} sessions", + "${clientSessions.values.length} sessions", ); for (final session in recipients) { session.sendEvent(e); From 4b4ad505f46eb8e927f73d3e18a17c3a30b2abc5 Mon Sep 17 00:00:00 2001 From: peerchemist Date: Fri, 19 Jun 2026 19:40:09 +0200 Subject: [PATCH 21/25] Simplify server API handler construction --- lib/src/server/api_handler.dart | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/lib/src/server/api_handler.dart b/lib/src/server/api_handler.dart index 06e1863..fa1777a 100644 --- a/lib/src/server/api_handler.dart +++ b/lib/src/server/api_handler.dart @@ -22,28 +22,21 @@ class ServerApiHandler implements ApiRequestInterface { static const currentProtocolVersion = 2; final ServerConfig config; - final ServerState state; - final Logger logger; + late final ServerState state; + late final Logger logger; final DateTime startTime = DateTime.now(); /// Creates a backend API handler with the [config]. /// /// A blank [state] and default [logger] will be created if not provided. ServerApiHandler({ - required ServerConfig config, - ServerState? state, - Logger? logger, - }) : this._( - config: config, - state: state, - logger: logger ?? createNoosphereRoastServerLogger(), - ); - - ServerApiHandler._({ required this.config, - required this.logger, ServerState? state, - }) : state = state ?? ServerState(logger: logger); + Logger? logger, + }) { + this.logger = logger ?? createNoosphereRoastServerLogger(); + this.state = state ?? ServerState(logger: this.logger); + } int get _participantN => config.group.participants.length; From 080a0cd8f5461f6b25e2a38fe5f8af9a98c2334b Mon Sep 17 00:00:00 2001 From: peerchemist Date: Sun, 21 Jun 2026 16:33:09 +0200 Subject: [PATCH 22/25] Don't need backward compatibility for the clients, SSE won't come back. --- lib/src/rest.dart | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/lib/src/rest.dart b/lib/src/rest.dart index dc9d4fa..7124055 100644 --- a/lib/src/rest.dart +++ b/lib/src/rest.dart @@ -49,10 +49,6 @@ Middleware restWebSocketCors({ return response.change(headers: {...response.headers, ...headers}); }; -@Deprecated('Use restWebSocketCors.') -Middleware restSseCors({String allowOrigin = '*'}) => - restWebSocketCors(allowOrigin: allowOrigin); - class RestWebSocketNoosphereService { final ServerApiHandler api; final String? allowOrigin; @@ -364,15 +360,6 @@ class RestWebSocketNoosphereService { } } -@Deprecated('Use RestWebSocketNoosphereService.') -class RestSseNoosphereService extends RestWebSocketNoosphereService { - RestSseNoosphereService({ - required super.api, - super.allowOrigin, - super.logger, - }); -} - Future _handleEmpty( Request request, Logger logger, @@ -533,6 +520,3 @@ String _eventType(Event event) => switch (event) { String restWebSocketSessionPath(SessionID sid) => '/sessions/${_encodeUrlBytes(sid.n)}/events'; - -@Deprecated('Use restWebSocketSessionPath.') -String restSseSessionPath(SessionID sid) => restWebSocketSessionPath(sid); From 4bc07267413b88dddc54dd4dceabb49ca66002cb Mon Sep 17 00:00:00 2001 From: peerchemist Date: Sun, 21 Jun 2026 16:49:03 +0200 Subject: [PATCH 23/25] Simplify REST transport helpers --- lib/src/rest.dart | 86 +++++++++++++++++++---------------------------- 1 file changed, 34 insertions(+), 52 deletions(-) diff --git a/lib/src/rest.dart b/lib/src/rest.dart index 7124055..a9d0e77 100644 --- a/lib/src/rest.dart +++ b/lib/src/rest.dart @@ -18,10 +18,8 @@ SignaturesRequestId _sigReqId(List li) => SignaturesRequestId.fromBytes(_bytes(li)); String _encodeBytes(List bytes) => base64Encode(bytes); -String _encodeUrlBytes(List bytes) => base64UrlEncode(bytes).replaceAll( - RegExp(r'=+$'), - '', - ); +String _encodeUrlBytes(List bytes) => + base64UrlEncode(bytes).replaceAll('=', ''); Uint8List _decodeBytes(String value) { final base64Value = value.replaceAll('-', '+').replaceAll('_', '/'); @@ -95,7 +93,7 @@ class RestWebSocketNoosphereService { shelf_io.serve(handler, address, port); Future _login(Request request) => - _handleJson(request, logger, () async { + _handleRequest(request, logger, () async { final json = await _readJson(request); final resp = await api.login( groupFingerprint: _fieldBytes(json, 'groupFingerprint'), @@ -107,7 +105,7 @@ class RestWebSocketNoosphereService { return _bytesResponse(resp.toBytes()); }); - Future _respondToChallenge(Request request) => _handleJson( + Future _respondToChallenge(Request request) => _handleRequest( request, logger, () async { @@ -123,7 +121,7 @@ class RestWebSocketNoosphereService { ); Future _extendSession(Request request) => - _handleJson(request, logger, () async { + _handleRequest(request, logger, () async { final json = await _readJson(request); final resp = await api.extendSession(_sid(_fieldBytes(json, 'sid'))); return _bytesResponse(resp.toBytes()); @@ -189,20 +187,22 @@ class RestWebSocketNoosphereService { final json = await _readJson(request); await api.sendDkgAcks( sid: _sid(_fieldBytes(json, 'sid')), - acks: _fieldStringList(json, 'acks') - .map((ack) => SignedDkgAck.fromBytes(_decodeBytes(ack))) - .toSet(), + acks: _fieldBytesList( + json, + 'acks', + ).map(SignedDkgAck.fromBytes).toSet(), ); }); Future _requestDkgAcks(Request request) => - _handleJson(request, logger, () async { + _handleRequest(request, logger, () async { final json = await _readJson(request); final resp = await api.requestDkgAcks( sid: _sid(_fieldBytes(json, 'sid')), - requests: _fieldStringList(json, 'requests') - .map((req) => DkgAckRequest.fromBytes(_decodeBytes(req))) - .toSet(), + requests: _fieldBytesList( + json, + 'requests', + ).map(DkgAckRequest.fromBytes).toSet(), ); return _repeatedBytesResponse(resp.map((ack) => ack.toBytes())); }); @@ -212,20 +212,18 @@ class RestWebSocketNoosphereService { final json = await _readJson(request); await api.requestSignatures( sid: _sid(_fieldBytes(json, 'sid')), - keys: _fieldStringList(json, 'keys') - .map((key) => AggregateKeyInfo.fromBytes(_decodeBytes(key))) - .toSet(), + keys: _fieldBytesList( + json, + 'keys', + ).map(AggregateKeyInfo.fromBytes).toSet(), signedDetails: Signed.fromBytes( _fieldBytes(json, 'signedDetails'), (reader) => SignaturesRequestDetails.fromReader(reader), ), - commitments: _fieldStringList(json, 'commitments') - .map( - (commitment) => SigningCommitment.fromBytes( - _decodeBytes(commitment), - ), - ) - .toList(), + commitments: _fieldBytesList( + json, + 'commitments', + ).map(SigningCommitment.fromBytes).toList(), ); }); @@ -239,14 +237,15 @@ class RestWebSocketNoosphereService { }); Future _submitSignatureReplies(Request request) => - _handleJson(request, logger, () async { + _handleRequest(request, logger, () async { final json = await _readJson(request); final resp = await api.submitSignatureReplies( sid: _sid(_fieldBytes(json, 'sid')), reqId: _sigReqId(_fieldBytes(json, 'reqId')), - replies: _fieldStringList(json, 'replies') - .map((reply) => SignatureReply.fromBytes(_decodeBytes(reply))) - .toList(), + replies: _fieldBytesList( + json, + 'replies', + ).map(SignatureReply.fromBytes).toList(), ); return _jsonResponse({ @@ -260,7 +259,7 @@ class RestWebSocketNoosphereService { }); Future _shareSecretShare(Request request) => - _handleJson(request, logger, () async { + _handleRequest(request, logger, () async { final json = await _readJson(request); final resp = await api.shareSecretShare( sid: _sid(_fieldBytes(json, 'sid')), @@ -365,33 +364,13 @@ Future _handleEmpty( Logger logger, Future Function() action, ) async { - final description = _requestDescription(request); - logger.d("REST $description received"); - try { + return _handleRequest(request, logger, () async { await action(); - logger.d("REST $description completed"); return _jsonResponse({}); - } on InvalidRequest catch (e) { - logger.w( - "REST $description rejected: ${e.message}", - ); - return _jsonResponse({'error': e.message}, status: 400); - } on FormatException catch (e) { - logger.w( - "REST $description rejected: ${e.message}", - ); - return _jsonResponse({'error': e.message}, status: 400); - } on Exception catch (e, stackTrace) { - logger.e( - "REST $description failed", - error: e, - stackTrace: stackTrace, - ); - return _jsonResponse({'error': 'Internal server error'}, status: 500); - } + }); } -Future _handleJson( +Future _handleRequest( Request request, Logger logger, Future Function() action, @@ -492,6 +471,9 @@ List _fieldStringList(Map json, String name) { }).toList(); } +Iterable _fieldBytesList(Map json, String name) => + _fieldStringList(json, name).map(_decodeBytes); + String _webSocketEvent(Event event, Logger logger) { final type = _eventType(event); logger.d("REST WebSocket sent $type"); From 6f30870e6ddaf68715c27d4ef1393bf113d5949a Mon Sep 17 00:00:00 2001 From: peerchemist Date: Sun, 21 Jun 2026 16:53:52 +0200 Subject: [PATCH 24/25] Add this comment about the REST api --- REST_API_SPEC.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/REST_API_SPEC.md b/REST_API_SPEC.md index b813286..5523c5f 100644 --- a/REST_API_SPEC.md +++ b/REST_API_SPEC.md @@ -15,6 +15,8 @@ All `POST` endpoints: - Request header should include `Content-Type: application/json`. - Binary/domain objects are base64 strings of the same `.toBytes()` payloads used by the gRPC client. +- The REST API takes the same binary payloads gRPC sends as protobuf `bytes`, + then base64-encodes them so JSON can carry them. - The server accepts standard base64 or URL-safe base64, with or without padding. - Do not send gRPC/protobuf wrapper messages to REST; send the underlying From 3710679ff8f87db87eeb636ff346b87e49b29a1d Mon Sep 17 00:00:00 2001 From: peerchemist Date: Sun, 21 Jun 2026 16:59:58 +0200 Subject: [PATCH 25/25] refactor: simplify REST/gRPC transport layer --- lib/src/common.dart | 7 ++++ lib/src/grpc.dart | 77 ++++++++++++++++++++--------------------- lib/src/rest.dart | 83 ++++++++++++++++++++------------------------- 3 files changed, 80 insertions(+), 87 deletions(-) create mode 100644 lib/src/common.dart diff --git a/lib/src/common.dart b/lib/src/common.dart new file mode 100644 index 0000000..48a09c3 --- /dev/null +++ b/lib/src/common.dart @@ -0,0 +1,7 @@ +import 'dart:typed_data'; +import 'package:noosphere_roast_client/noosphere_roast_client.dart'; + +Uint8List bytes(List li) => Uint8List.fromList(li); +SessionID sid(List li) => SessionID.fromBytes(bytes(li)); +SignaturesRequestId sigReqId(List li) => + SignaturesRequestId.fromBytes(bytes(li)); diff --git a/lib/src/grpc.dart b/lib/src/grpc.dart index 743fa55..61a0119 100644 --- a/lib/src/grpc.dart +++ b/lib/src/grpc.dart @@ -1,17 +1,12 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:coinlib/coinlib.dart' as cl; import 'package:grpc/grpc.dart' as grpc; import 'package:noosphere_roast_client/pbgrpc.dart' as pb; import 'package:noosphere_roast_client/noosphere_roast_client.dart'; +import 'package:noosphere_roast_server/src/common.dart' as common; import 'package:noosphere_roast_server/src/logging.dart'; import 'package:noosphere_roast_server/src/server/api_handler.dart'; import 'package:noosphere_roast_server/src/server/state/client_session.dart'; - -Uint8List _bytes(List li) => Uint8List.fromList(li); -SessionID _sid(List li) => SessionID.fromBytes(_bytes(li)); -SignaturesRequestId _sigReqId(List li) => - SignaturesRequestId.fromBytes(_bytes(li)); pb.Bytes _returnWritable(cl.Writable writable) => pb.Bytes( data: writable.toBytes(), ); @@ -73,9 +68,9 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { ) => _handleExceptions("login", () async { final resp = await api.login( - groupFingerprint: _bytes(request.groupFingerprint), + groupFingerprint: common.bytes(request.groupFingerprint), participantId: Identifier.fromBytes( - _bytes(request.participantId), + common.bytes(request.participantId), ), protocolVersion: request.protocolVersion, ); @@ -91,8 +86,8 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { _handleExceptions("respondToChallenge", () async { final resp = await api.respondToChallenge( Signed( - obj: AuthChallenge.fromBytes(_bytes(request.challenge)), - signature: cl.SchnorrSignature(_bytes(request.signature)), + obj: AuthChallenge.fromBytes(common.bytes(request.challenge)), + signature: cl.SchnorrSignature(common.bytes(request.signature)), ), ); @@ -104,7 +99,7 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { grpc.ServiceCall call, pb.Bytes request, ) { - final sessionId = _sid(request.data); + final sessionId = common.sid(request.data); late final ClientSession session; try { session = api.getSession(sessionId); @@ -169,7 +164,7 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { pb.Bytes request, ) => _handleExceptions("extendSession", () async { - final resp = await api.extendSession(_sid(request.data)); + final resp = await api.extendSession(common.sid(request.data)); return _returnWritable(resp); }); @@ -181,13 +176,13 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { _handleEmpty( "requestNewDkg", () => api.requestNewDkg( - sid: _sid(request.sid), + sid: common.sid(request.sid), signedDetails: Signed.fromBytes( - _bytes(request.signedDetails), + common.bytes(request.signedDetails), (reader) => NewDkgDetails.fromReader(reader), ), commitment: DkgPublicCommitment.fromBytes( - _bytes(request.commitment), + common.bytes(request.commitment), ), ), ); @@ -199,7 +194,7 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { ) => _handleEmpty( "rejectDkg", - () => api.rejectDkg(sid: _sid(request.sid), name: request.name), + () => api.rejectDkg(sid: common.sid(request.sid), name: request.name), ); @override @@ -210,10 +205,10 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { _handleEmpty( "submitDkgCommitment", () => api.submitDkgCommitment( - sid: _sid(request.sid), + sid: common.sid(request.sid), name: request.name, commitment: DkgPublicCommitment.fromBytes( - _bytes(request.commitment), + common.bytes(request.commitment), ), ), ); @@ -226,15 +221,15 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { _handleEmpty( "submitDkgRound2", () => api.submitDkgRound2( - sid: _sid(request.sid), + sid: common.sid(request.sid), name: request.name, commitmentSetSignature: cl.SchnorrSignature( - _bytes(request.commitmentSetSignature), + common.bytes(request.commitmentSetSignature), ), secrets: { for (final secret in request.secrets) - Identifier.fromBytes(_bytes(secret.id)): DkgEncryptedSecret( - ECCiphertext.fromBytes(_bytes(secret.secret)), + Identifier.fromBytes(common.bytes(secret.id)): DkgEncryptedSecret( + ECCiphertext.fromBytes(common.bytes(secret.secret)), ), }, ), @@ -248,10 +243,10 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { _handleEmpty( "sendDkgAcks", () => api.sendDkgAcks( - sid: _sid(request.sid), + sid: common.sid(request.sid), acks: request.acks .map( - (ack) => SignedDkgAck.fromBytes(_bytes(ack)), + (ack) => SignedDkgAck.fromBytes(common.bytes(ack)), ) .toSet(), ), @@ -264,10 +259,10 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { ) => _handleExceptions("requestDkgAcks", () async { final resp = await api.requestDkgAcks( - sid: _sid(request.sid), + sid: common.sid(request.sid), requests: request.requests .map( - (request) => DkgAckRequest.fromBytes(_bytes(request)), + (request) => DkgAckRequest.fromBytes(common.bytes(request)), ) .toSet(), ); @@ -283,19 +278,19 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { _handleEmpty( "requestSignatures", () => api.requestSignatures( - sid: _sid(request.sid), + sid: common.sid(request.sid), keys: request.keys .map( - (key) => AggregateKeyInfo.fromBytes(_bytes(key)), + (key) => AggregateKeyInfo.fromBytes(common.bytes(key)), ) .toSet(), signedDetails: Signed.fromBytes( - _bytes(request.signedDetails), + common.bytes(request.signedDetails), (reader) => SignaturesRequestDetails.fromReader(reader), ), commitments: request.commitments .map( - (commitment) => SigningCommitment.fromBytes(_bytes(commitment)), + (commitment) => SigningCommitment.fromBytes(common.bytes(commitment)), ) .toList(), ), @@ -309,8 +304,8 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { _handleEmpty( "rejectSignaturesRequest", () => api.rejectSignaturesRequest( - sid: _sid(request.sid), - reqId: _sigReqId(request.reqId), + sid: common.sid(request.sid), + reqId: common.sigReqId(request.reqId), ), ); @@ -321,11 +316,11 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { ) => _handleExceptions("submitSignatureReplies", () async { final resp = await api.submitSignatureReplies( - sid: _sid(request.sid), - reqId: _sigReqId(request.reqId), + sid: common.sid(request.sid), + reqId: common.sigReqId(request.reqId), replies: request.replies .map( - (reply) => SignatureReply.fromBytes(_bytes(reply)), + (reply) => SignatureReply.fromBytes(common.bytes(reply)), ) .toList(), ); @@ -349,12 +344,12 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { ) => _handleExceptions("shareSecretShare", () async { final resp = await api.shareSecretShare( - sid: _sid(request.sid), - groupKey: cl.ECCompressedPublicKey(_bytes(request.groupKey)), + sid: common.sid(request.sid), + groupKey: cl.ECCompressedPublicKey(common.bytes(request.groupKey)), encryptedSecrets: { for (final secret in request.secrets) - Identifier.fromBytes(_bytes(secret.id)): EncryptedKeyShare( - ECCiphertext.fromBytes(_bytes(secret.share)), + Identifier.fromBytes(common.bytes(secret.id)): EncryptedKeyShare( + ECCiphertext.fromBytes(common.bytes(secret.share)), ), }, ); @@ -370,9 +365,9 @@ class FrostNoosphereService extends pb.NoosphereServiceBase { _handleEmpty( "ackKeyConstructed", () => api.ackKeyConstructed( - sid: _sid(request.sid), + sid: common.sid(request.sid), constructedKey: Signed.fromBytes( - _bytes(request.constructedKey), + common.bytes(request.constructedKey), (reader) => KeyWasConstructed.fromReader(reader), ), ), diff --git a/lib/src/rest.dart b/lib/src/rest.dart index a9d0e77..2dd34da 100644 --- a/lib/src/rest.dart +++ b/lib/src/rest.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:coinlib/coinlib.dart' as cl; import 'package:noosphere_roast_client/noosphere_roast_client.dart'; +import 'package:noosphere_roast_server/src/common.dart' as common; import 'package:noosphere_roast_server/src/logging.dart'; import 'package:noosphere_roast_server/src/server/api_handler.dart'; import 'package:shelf/shelf.dart'; @@ -12,13 +13,8 @@ import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_web_socket/shelf_web_socket.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -Uint8List _bytes(List li) => Uint8List.fromList(li); -SessionID _sid(List li) => SessionID.fromBytes(_bytes(li)); -SignaturesRequestId _sigReqId(List li) => - SignaturesRequestId.fromBytes(_bytes(li)); - -String _encodeBytes(List bytes) => base64Encode(bytes); -String _encodeUrlBytes(List bytes) => +String _base64Encode(List bytes) => base64Encode(bytes); +String _base64UrlEncode(List bytes) => base64UrlEncode(bytes).replaceAll('=', ''); Uint8List _decodeBytes(String value) { @@ -123,7 +119,7 @@ class RestWebSocketNoosphereService { Future _extendSession(Request request) => _handleRequest(request, logger, () async { final json = await _readJson(request); - final resp = await api.extendSession(_sid(_fieldBytes(json, 'sid'))); + final resp = await api.extendSession(common.sid(_fieldBytes(json, 'sid'))); return _bytesResponse(resp.toBytes()); }); @@ -131,7 +127,7 @@ class RestWebSocketNoosphereService { _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.requestNewDkg( - sid: _sid(_fieldBytes(json, 'sid')), + sid: common.sid(_fieldBytes(json, 'sid')), signedDetails: Signed.fromBytes( _fieldBytes(json, 'signedDetails'), (reader) => NewDkgDetails.fromReader(reader), @@ -146,7 +142,7 @@ class RestWebSocketNoosphereService { _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.rejectDkg( - sid: _sid(_fieldBytes(json, 'sid')), + sid: common.sid(_fieldBytes(json, 'sid')), name: _fieldString(json, 'name'), ); }); @@ -155,7 +151,7 @@ class RestWebSocketNoosphereService { _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.submitDkgCommitment( - sid: _sid(_fieldBytes(json, 'sid')), + sid: common.sid(_fieldBytes(json, 'sid')), name: _fieldString(json, 'name'), commitment: DkgPublicCommitment.fromBytes( _fieldBytes(json, 'commitment'), @@ -167,7 +163,7 @@ class RestWebSocketNoosphereService { _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.submitDkgRound2( - sid: _sid(_fieldBytes(json, 'sid')), + sid: common.sid(_fieldBytes(json, 'sid')), name: _fieldString(json, 'name'), commitmentSetSignature: cl.SchnorrSignature( _fieldBytes(json, 'commitmentSetSignature'), @@ -186,7 +182,7 @@ class RestWebSocketNoosphereService { _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.sendDkgAcks( - sid: _sid(_fieldBytes(json, 'sid')), + sid: common.sid(_fieldBytes(json, 'sid')), acks: _fieldBytesList( json, 'acks', @@ -198,7 +194,7 @@ class RestWebSocketNoosphereService { _handleRequest(request, logger, () async { final json = await _readJson(request); final resp = await api.requestDkgAcks( - sid: _sid(_fieldBytes(json, 'sid')), + sid: common.sid(_fieldBytes(json, 'sid')), requests: _fieldBytesList( json, 'requests', @@ -211,7 +207,7 @@ class RestWebSocketNoosphereService { _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.requestSignatures( - sid: _sid(_fieldBytes(json, 'sid')), + sid: common.sid(_fieldBytes(json, 'sid')), keys: _fieldBytesList( json, 'keys', @@ -231,8 +227,8 @@ class RestWebSocketNoosphereService { _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.rejectSignaturesRequest( - sid: _sid(_fieldBytes(json, 'sid')), - reqId: _sigReqId(_fieldBytes(json, 'reqId')), + sid: common.sid(_fieldBytes(json, 'sid')), + reqId: common.sigReqId(_fieldBytes(json, 'reqId')), ); }); @@ -240,8 +236,8 @@ class RestWebSocketNoosphereService { _handleRequest(request, logger, () async { final json = await _readJson(request); final resp = await api.submitSignatureReplies( - sid: _sid(_fieldBytes(json, 'sid')), - reqId: _sigReqId(_fieldBytes(json, 'reqId')), + sid: common.sid(_fieldBytes(json, 'sid')), + reqId: common.sigReqId(_fieldBytes(json, 'reqId')), replies: _fieldBytesList( json, 'replies', @@ -254,7 +250,7 @@ class RestWebSocketNoosphereService { SignaturesCompleteResponse() => 'complete', null => 'empty', }, - 'data': resp == null ? null : _encodeBytes(resp.toBytes()), + 'data': resp == null ? null : _base64Encode(resp.toBytes()), }); }); @@ -262,7 +258,7 @@ class RestWebSocketNoosphereService { _handleRequest(request, logger, () async { final json = await _readJson(request); final resp = await api.shareSecretShare( - sid: _sid(_fieldBytes(json, 'sid')), + sid: common.sid(_fieldBytes(json, 'sid')), groupKey: cl.ECCompressedPublicKey(_fieldBytes(json, 'groupKey')), encryptedSecrets: { for (final secret in _fieldList(json, 'secrets')) @@ -279,7 +275,7 @@ class RestWebSocketNoosphereService { _handleEmpty(request, logger, () async { final json = await _readJson(request); await api.ackKeyConstructed( - sid: _sid(_fieldBytes(json, 'sid')), + sid: common.sid(_fieldBytes(json, 'sid')), constructedKey: Signed.fromBytes( _fieldBytes(json, 'constructedKey'), (reader) => KeyWasConstructed.fromReader(reader), @@ -291,7 +287,7 @@ class RestWebSocketNoosphereService { final description = _requestDescription(request); logger.d("REST $description received"); try { - final session = api.getSession(_sid(_decodeBytes(sid))); + final session = api.getSession(common.sid(_decodeBytes(sid))); final handler = webSocketHandler( (WebSocketChannel webSocket, String? _) { logger.d("REST $description opened"); @@ -359,6 +355,11 @@ class RestWebSocketNoosphereService { } } +Response _rejectResponse(String description, String message, Logger logger) { + logger.w("REST $description rejected: $message"); + return _jsonResponse({'error': message}, status: 400); +} + Future _handleEmpty( Request request, Logger logger, @@ -382,15 +383,9 @@ Future _handleRequest( logger.d("REST $description completed"); return response; } on InvalidRequest catch (e) { - logger.w( - "REST $description rejected: ${e.message}", - ); - return _jsonResponse({'error': e.message}, status: 400); + return _rejectResponse(description, e.message, logger); } on FormatException catch (e) { - logger.w( - "REST $description rejected: ${e.message}", - ); - return _jsonResponse({'error': e.message}, status: 400); + return _rejectResponse(description, e.message, logger); } on Exception catch (e, stackTrace) { logger.e( "REST $description failed", @@ -409,11 +404,10 @@ String _requestDescription(Request request) { Future> _readJson(Request request) async { final body = await request.readAsString(); final decoded = jsonDecode(body); - if (decoded is! Map) throw const FormatException('Expected JSON object'); - return { - for (final entry in decoded.entries) - if (entry.key is String) entry.key as String: entry.value, - }; + if (decoded is! Map) { + throw const FormatException('Expected JSON object'); + } + return decoded; } Response _jsonResponse(Object value, {int status = 200}) => Response( @@ -423,10 +417,10 @@ Response _jsonResponse(Object value, {int status = 200}) => Response( ); Response _bytesResponse(List bytes) => - _jsonResponse({'data': _encodeBytes(bytes)}); + _jsonResponse({'data': _base64Encode(bytes)}); Response _repeatedBytesResponse(Iterable> bytes) => - _jsonResponse({'data': bytes.map(_encodeBytes).toList()}); + _jsonResponse({'data': bytes.map(_base64Encode).toList()}); String _fieldString(Map json, String name) { final value = json[name]; @@ -460,26 +454,23 @@ List> _fieldList(Map json, String name) { }).toList(); } -List _fieldStringList(Map json, String name) { +Iterable _fieldBytesList(Map json, String name) { final value = json[name]; if (value is! List) throw FormatException('Expected "$name" to be a list'); return value.map((entry) { if (entry is! String) { throw FormatException('Expected "$name" entries to be strings'); } - return entry; - }).toList(); + return _decodeBytes(entry); + }); } -Iterable _fieldBytesList(Map json, String name) => - _fieldStringList(json, name).map(_decodeBytes); - String _webSocketEvent(Event event, Logger logger) { final type = _eventType(event); logger.d("REST WebSocket sent $type"); return jsonEncode({ 'type': type, - 'data': _encodeBytes(event.toBytes()), + 'data': _base64Encode(event.toBytes()), }); } @@ -501,4 +492,4 @@ String _eventType(Event event) => switch (event) { }; String restWebSocketSessionPath(SessionID sid) => - '/sessions/${_encodeUrlBytes(sid.n)}/events'; + '/sessions/${_base64UrlEncode(sid.n)}/events';