import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:bip340/bip340.dart' as bip340; import 'package:crypto/crypto.dart'; import 'package:swarm/src/nip44.dart'; import 'package:swarm/swarm.dart'; import 'package:test/test.dart'; // Well-known secp256k1 key pairs used as signer (k=2) and app (k=3). const _signerPrivkey = '0000000000000000000000000000000000000000000000000000000000000002'; const _appPrivkey = '0000000000000000000000000000000000000000000000000000000000000003'; final _signerPubkey = bip340.getPublicKey(_signerPrivkey); final _appPubkey = bip340.getPublicKey(_appPrivkey); String _bunkerUri() => 'bunker://$_signerPubkey?relay=ws://test&secret=test-secret'; void main() { late _FakeNip46Relay fakeRelay; late Nip46SignerActor actor; setUp(() { fakeRelay = _FakeNip46Relay(signerPrivkey: _signerPrivkey); actor = Nip46SignerActor( bunkerUri: _bunkerUri(), appPrivkey: _appPrivkey, connectionFactory: (_) => fakeRelay, timeout: const Duration(seconds: 5), ); }); tearDown(() async { await actor.close(); await fakeRelay.dispose(); }); test('GetPublicKey returns signer pubkey', () async { final result = await actor.handle(const GetPublicKey()); expect(result, isA()); expect((result as PublicKeyResult).publicKey, equals(_signerPubkey)); }); test('GetPublicKey is cached — relay receives only one get_public_key request', () async { await actor.handle(const GetPublicKey()); await actor.handle(const GetPublicKey()); final count = fakeRelay.methods.where((m) => m == 'get_public_key').length; expect(count, equals(1)); }); test('SignEvent returns EventSigned', () async { final result = await actor.handle( const SignEvent(kind: 1, content: 'hello nip46'), ); expect(result, isA()); }); test('signed event is signed by the signer, not the app', () async { final result = await actor.handle(const SignEvent(kind: 1, content: 'test')) as EventSigned; final event = jsonDecode(result.event) as Map; expect(event['pubkey'], equals(_signerPubkey)); }); test('signed event signature verifies', () async { final result = await actor.handle(const SignEvent(kind: 1, content: 'verify me')) as EventSigned; final event = jsonDecode(result.event) as Map; expect( bip340.verify( event['pubkey'] as String, event['id'] as String, event['sig'] as String, ), isTrue, ); }); test('signed event preserves kind, content, tags', () async { final result = await actor.handle( const SignEvent( kind: 30023, content: 'article body', tags: [ ['d', 'my-article'], ['title', 'Hello'], ], ), ) as EventSigned; final event = jsonDecode(result.event) as Map; expect(event['kind'], equals(30023)); expect(event['content'], equals('article body')); final tags = event['tags'] as List; expect(tags[0][0], equals('d')); expect(tags[1][0], equals('title')); }); test('multiple sequential sign requests all succeed', () async { for (var i = 0; i < 3; i++) { final result = await actor.handle( SignEvent(kind: 1, content: 'message $i'), ); expect(result, isA()); } }); test('request timeout throws', () async { fakeRelay.dropResponses = true; final fastActor = Nip46SignerActor( bunkerUri: _bunkerUri(), appPrivkey: _appPrivkey, connectionFactory: (_) => fakeRelay, timeout: const Duration(milliseconds: 100), ); await expectLater( fastActor.handle(const GetPublicKey()), throwsA(isA()), ); await fastActor.close(); }); } // --------------------------------------------------------------------------- // Fake NIP-46 signer relay class _FakeNip46Relay implements RelayConnection { final String signerPrivkey; final String signerPubkey; final _controller = StreamController.broadcast(); bool dropResponses = false; /// Methods received (after connect), for assertion purposes. final methods = []; _FakeNip46Relay({required this.signerPrivkey}) : signerPubkey = bip340.getPublicKey(signerPrivkey); @override Future connect() async {} @override Stream get messages => _controller.stream; @override Future send(String message) async { final list = jsonDecode(message) as List; switch (list[0] as String) { case 'REQ': final subId = list[1] as String; _push(jsonEncode(['EOSE', subId])); case 'EVENT': final event = list[1] as Map; if (event['kind'] == 24133) { _handleNip46(event); } else { _push(jsonEncode(['OK', event['id'], true, ''])); } } } @override Future close() async {} Future dispose() => _controller.close(); void _push(String message) { if (!_controller.isClosed) Future(() { if (!_controller.isClosed) _controller.add(message); }); } void _handleNip46(Map event) { if (dropResponses) return; try { final appPubkey = event['pubkey'] as String; final decrypted = nip44Decrypt(event['content'] as String, signerPrivkey, appPubkey); final req = jsonDecode(decrypted) as Map; final id = req['id'] as String; final method = req['method'] as String; final params = req['params'] as List; methods.add(method); final String result; switch (method) { case 'connect': result = 'ack'; case 'get_public_key': result = signerPubkey; case 'sign_event': final unsigned = jsonDecode(params[0] as String) as Map; result = _sign(unsigned); default: result = ''; } final response = jsonEncode({'id': id, 'result': result, 'error': ''}); final encrypted = nip44Encrypt(response, signerPrivkey, appPubkey); final responseEvent = _signEvent( kind: 24133, tags: [['p', appPubkey]], content: encrypted, ); // Push the response EVENT, then OK for the request. _push(jsonEncode(['EVENT', 'sub', responseEvent])); _push(jsonEncode(['OK', event['id'], true, ''])); } catch (e) { // ignore malformed requests in tests } } String _sign(Map unsigned) { final ts = unsigned['created_at'] as int; final kind = unsigned['kind'] as int; final rawTags = unsigned['tags'] as List; final tags = rawTags.map((t) => (t as List).cast()).toList(); final content = unsigned['content'] as String; final ser = jsonEncode([0, signerPubkey, ts, kind, tags, content]); final id = sha256 .convert(utf8.encode(ser)) .bytes .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); final rng = Random.secure(); final aux = List.generate(32, (_) => rng.nextInt(256)) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); final sig = bip340.sign(signerPrivkey, id, aux); return jsonEncode({ 'id': id, 'pubkey': signerPubkey, 'created_at': ts, 'kind': kind, 'tags': tags, 'content': content, 'sig': sig, }); } Map _signEvent({ required int kind, required List> tags, required String content, }) { final ts = DateTime.now().millisecondsSinceEpoch ~/ 1000; final ser = jsonEncode([0, signerPubkey, ts, kind, tags, content]); final id = sha256 .convert(utf8.encode(ser)) .bytes .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); final rng = Random.secure(); final aux = List.generate(32, (_) => rng.nextInt(256)) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); final sig = bip340.sign(signerPrivkey, id, aux); return { 'id': id, 'pubkey': signerPubkey, 'created_at': ts, 'kind': kind, 'tags': tags, 'content': content, 'sig': sig, }; } }