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 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-06-08 15:27:31 +03:00
parent 4b48c27e31
commit 128190975e
4 changed files with 540 additions and 0 deletions

View file

@ -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://<signerPubkey>?relay=<url>&secret=<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<SignerMessage, SignerResult> {
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<String>? _messageSub;
String? _cachedPublicKey;
final _pending = <String, Completer<String>>{};
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<SignerResult> 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<void> close() async {
await _messageSub?.cancel();
await _conn?.close();
for (final c in _pending.values) {
c.completeError(StateError('Nip46SignerActor closed'));
}
_pending.clear();
}
// ---------------------------------------------------------------------------
Future<void> _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<PublicKeyResult> _getPublicKey() async {
_cachedPublicKey ??= await _request('get_public_key', []);
return PublicKeyResult(_cachedPublicKey!);
}
Future<EventSigned> _remoteSignEvent(
int kind,
List<List<String>> 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<String> _request(String method, List<dynamic> 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<String>();
_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<String, dynamic>;
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<String, dynamic>;
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<List<String>> 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();
}
}

View file

@ -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';