From 128190975e28a54f827ab91cd5e032e07e13c78a Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 8 Jun 2026 15:27:31 +0300 Subject: [PATCH] nip46 remote signer actor Drop-in replacement for NostrSignerActor that delegates signing to an external signer over NIP-46 (bunker:// URI, NIP-44 encrypted kind-24133). Includes 8 tests with a fully self-contained fake relay helper. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 49 ++++++ lib/src/nip46_signer_actor.dart | 217 ++++++++++++++++++++++++ lib/swarm.dart | 1 + test/nip46_signer_actor_test.dart | 273 ++++++++++++++++++++++++++++++ 4 files changed, 540 insertions(+) create mode 100644 lib/src/nip46_signer_actor.dart create mode 100644 test/nip46_signer_actor_test.dart diff --git a/README.md b/README.md index 35a9709..6e9b675 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,55 @@ final eventJson = (signed as EventSigned).event; // ready to publish --- +## Nip46SignerActor + +NIP-46 remote signer — drop-in replacement for `NostrSignerActor`. Delegates +signing to an external signer (e.g. [Nsec.app](https://nsec.app)) over a Nostr +relay using NIP-44 encrypted kind-24133 events. Accepts a `bunker://` URI. + +```dart +final actor = Nip46SignerActor( + bunkerUri: 'bunker://?relay=wss://relay.nsec.app&secret=', +); +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `bunkerUri` | `String` | required | Bunker URI from the remote signer app | +| `appPrivkey` | `String?` | random | App's ephemeral private key (injectable for tests) | +| `connectionFactory` | `RelayConnection Function(String)?` | `WebSocketRelayConnection.new` | Override relay factory for tests | +| `timeout` | `Duration` | 30 s | Per-request timeout | + +Implements the same `SignerMessage`/`SignerResult` protocol as `NostrSignerActor`: + +| Message | Result | Description | +|---|---|---| +| `GetPublicKey()` | `PublicKeyResult(publicKey)` | Returns the signer's public key (cached after first call) | +| `SignEvent(kind, content, tags?, createdAt?)` | `EventSigned(event)` | Sends a `sign_event` request to the remote signer; returns the fully signed event JSON | + +Call `actor.close()` when done to tear down the relay connection. + +```dart +final signer = Nip46SignerActor( + bunkerUri: bunkerUri, // scanned from QR or pasted by user +); + +final pk = await signer.handle(const GetPublicKey()); +print((pk as PublicKeyResult).publicKey); + +final signed = await signer.handle( + const SignEvent(kind: 1, content: 'hello from mobile'), +); +final eventJson = (signed as EventSigned).event; + +await signer.close(); +``` + +The actor automatically sends a `connect` handshake on first use and reuses the +relay connection for all subsequent requests. + +--- + ## NostrPublishActor Publishes signed Nostr events to one or more relays and collects OK/rejection diff --git a/lib/src/nip46_signer_actor.dart b/lib/src/nip46_signer_actor.dart new file mode 100644 index 0000000..fdf4249 --- /dev/null +++ b/lib/src/nip46_signer_actor.dart @@ -0,0 +1,217 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:actors/actors.dart'; +import 'package:bip340/bip340.dart' as bip340; +import 'package:crypto/crypto.dart'; + +import 'nip44.dart'; +import 'nostr_signer_actor.dart'; +import 'relay_connection.dart'; + +/// NIP-46 remote signer — drop-in replacement for [NostrSignerActor]. +/// +/// Accepts a bunker URI (`bunker://?relay=&secret=`), +/// generates an ephemeral app keypair, and delegates signing to the remote +/// signer via NIP-44 encrypted kind-24133 events. +/// +/// Call [close] when done to tear down the relay connection. +class Nip46SignerActor with Handler { + final String _signerPubkey; + final String _relayUrl; + final String? _secret; + final String _appPrivkey; + final String _appPubkey; + final RelayConnection Function(String) _connectionFactory; + final Duration _timeout; + + RelayConnection? _conn; + StreamSubscription? _messageSub; + String? _cachedPublicKey; + final _pending = >{}; + bool _connected = false; + + factory Nip46SignerActor({ + required String bunkerUri, + String? appPrivkey, + RelayConnection Function(String)? connectionFactory, + Duration timeout = const Duration(seconds: 30), + }) { + final uri = Uri.parse(bunkerUri.replaceFirst('bunker://', 'https://')); + final signerPubkey = uri.host; + final relayUrl = uri.queryParameters['relay']!; + final secret = uri.queryParameters['secret']; + final rng = Random.secure(); + final privkey = appPrivkey ?? + List.generate(32, (_) => rng.nextInt(256)) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + return Nip46SignerActor._( + signerPubkey: signerPubkey, + relayUrl: relayUrl, + secret: secret, + appPrivkey: privkey, + appPubkey: bip340.getPublicKey(privkey), + connectionFactory: connectionFactory ?? WebSocketRelayConnection.new, + timeout: timeout, + ); + } + + Nip46SignerActor._({ + required String signerPubkey, + required String relayUrl, + String? secret, + required String appPrivkey, + required String appPubkey, + required RelayConnection Function(String) connectionFactory, + required Duration timeout, + }) : _signerPubkey = signerPubkey, + _relayUrl = relayUrl, + _secret = secret, + _appPrivkey = appPrivkey, + _appPubkey = appPubkey, + _connectionFactory = connectionFactory, + _timeout = timeout; + + @override + Future handle(SignerMessage message) async { + await _ensureConnected(); + return switch (message) { + GetPublicKey() => _getPublicKey(), + SignEvent(:final kind, :final tags, :final content, :final createdAt) => + _remoteSignEvent(kind, tags, content, createdAt), + }; + } + + @override + Future close() async { + await _messageSub?.cancel(); + await _conn?.close(); + for (final c in _pending.values) { + c.completeError(StateError('Nip46SignerActor closed')); + } + _pending.clear(); + } + + // --------------------------------------------------------------------------- + + Future _ensureConnected() async { + if (_connected) return; + final conn = _connectionFactory(_relayUrl); + _conn = conn; + await conn.connect(); + + _messageSub = conn.messages.listen(_onMessage); + + final rng = Random(); + final subId = + List.generate(16, (_) => rng.nextInt(16).toRadixString(16)).join(); + await conn.send(jsonEncode([ + 'REQ', + subId, + { + 'kinds': [24133], + '#p': [_appPubkey], + } + ])); + + await _request('connect', [_appPubkey, _secret ?? '', 'sign_event,get_public_key']); + _connected = true; + } + + Future _getPublicKey() async { + _cachedPublicKey ??= await _request('get_public_key', []); + return PublicKeyResult(_cachedPublicKey!); + } + + Future _remoteSignEvent( + int kind, + List> tags, + String content, + int? createdAt, + ) async { + final ts = createdAt ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; + final unsigned = {'kind': kind, 'content': content, 'tags': tags, 'created_at': ts}; + final result = await _request('sign_event', [jsonEncode(unsigned)]); + return EventSigned(result); + } + + Future _request(String method, List params) async { + final id = _randomId(); + final body = jsonEncode({'id': id, 'method': method, 'params': params}); + final encrypted = nip44Encrypt(body, _appPrivkey, _signerPubkey); + final eventJson = _signAppEvent(kind: 24133, tags: [['p', _signerPubkey]], content: encrypted); + + final completer = Completer(); + _pending[id] = completer; + await _conn!.send(jsonEncode(['EVENT', jsonDecode(eventJson)])); + + return completer.future.timeout(_timeout, onTimeout: () { + _pending.remove(id); + throw Exception('NIP-46 request timed out: $method'); + }); + } + + void _onMessage(String raw) { + try { + final list = jsonDecode(raw) as List; + if (list[0] != 'EVENT') return; + final event = list[2] as Map; + if (event['kind'] != 24133) return; + if (event['pubkey'] != _signerPubkey) return; + + final decrypted = + nip44Decrypt(event['content'] as String, _appPrivkey, _signerPubkey); + final response = jsonDecode(decrypted) as Map; + final id = response['id'] as String; + final error = response['error'] as String? ?? ''; + final result = response['result'] as String? ?? ''; + + final completer = _pending.remove(id); + if (completer == null) return; + if (error.isNotEmpty) { + completer.completeError(Exception('NIP-46 error: $error')); + } else { + completer.complete(result); + } + } catch (_) { + // malformed message — ignore + } + } + + String _signAppEvent({ + required int kind, + required List> tags, + required String content, + }) { + final ts = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final ser = jsonEncode([0, _appPubkey, 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(_appPrivkey, id, aux); + return jsonEncode({ + 'id': id, + 'pubkey': _appPubkey, + 'created_at': ts, + 'kind': kind, + 'tags': tags, + 'content': content, + 'sig': sig, + }); + } + + static String _randomId() { + final rng = Random.secure(); + return List.generate(32, (_) => rng.nextInt(256)) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + } +} diff --git a/lib/swarm.dart b/lib/swarm.dart index 99d12fe..b052d87 100644 --- a/lib/swarm.dart +++ b/lib/swarm.dart @@ -7,3 +7,4 @@ export 'src/geo_actor.dart'; export 'src/file_upload_actor.dart'; export 'src/file_download_actor.dart'; export 'src/nostr_account_actor.dart'; +export 'src/nip46_signer_actor.dart'; diff --git a/test/nip46_signer_actor_test.dart b/test/nip46_signer_actor_test.dart new file mode 100644 index 0000000..cce2825 --- /dev/null +++ b/test/nip46_signer_actor_test.dart @@ -0,0 +1,273 @@ +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, + }; + } +}