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:
parent
4b48c27e31
commit
128190975e
4 changed files with 540 additions and 0 deletions
49
README.md
49
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://<signerPubkey>?relay=wss://relay.nsec.app&secret=<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
|
## NostrPublishActor
|
||||||
|
|
||||||
Publishes signed Nostr events to one or more relays and collects OK/rejection
|
Publishes signed Nostr events to one or more relays and collects OK/rejection
|
||||||
|
|
|
||||||
217
lib/src/nip46_signer_actor.dart
Normal file
217
lib/src/nip46_signer_actor.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,3 +7,4 @@ export 'src/geo_actor.dart';
|
||||||
export 'src/file_upload_actor.dart';
|
export 'src/file_upload_actor.dart';
|
||||||
export 'src/file_download_actor.dart';
|
export 'src/file_download_actor.dart';
|
||||||
export 'src/nostr_account_actor.dart';
|
export 'src/nostr_account_actor.dart';
|
||||||
|
export 'src/nip46_signer_actor.dart';
|
||||||
|
|
|
||||||
273
test/nip46_signer_actor_test.dart
Normal file
273
test/nip46_signer_actor_test.dart
Normal file
|
|
@ -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<PublicKeyResult>());
|
||||||
|
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<EventSigned>());
|
||||||
|
});
|
||||||
|
|
||||||
|
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<String, dynamic>;
|
||||||
|
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<String, dynamic>;
|
||||||
|
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<String, dynamic>;
|
||||||
|
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<EventSigned>());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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<Exception>()),
|
||||||
|
);
|
||||||
|
await fastActor.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fake NIP-46 signer relay
|
||||||
|
|
||||||
|
class _FakeNip46Relay implements RelayConnection {
|
||||||
|
final String signerPrivkey;
|
||||||
|
final String signerPubkey;
|
||||||
|
final _controller = StreamController<String>.broadcast();
|
||||||
|
bool dropResponses = false;
|
||||||
|
|
||||||
|
/// Methods received (after connect), for assertion purposes.
|
||||||
|
final methods = <String>[];
|
||||||
|
|
||||||
|
_FakeNip46Relay({required this.signerPrivkey})
|
||||||
|
: signerPubkey = bip340.getPublicKey(signerPrivkey);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> connect() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> get messages => _controller.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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<String, dynamic>;
|
||||||
|
if (event['kind'] == 24133) {
|
||||||
|
_handleNip46(event);
|
||||||
|
} else {
|
||||||
|
_push(jsonEncode(['OK', event['id'], true, '']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {}
|
||||||
|
|
||||||
|
Future<void> dispose() => _controller.close();
|
||||||
|
|
||||||
|
void _push(String message) {
|
||||||
|
if (!_controller.isClosed) Future(() {
|
||||||
|
if (!_controller.isClosed) _controller.add(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleNip46(Map<String, dynamic> 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<String, dynamic>;
|
||||||
|
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<String, dynamic>;
|
||||||
|
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<String, dynamic> 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<String>()).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<String, dynamic> _signEvent({
|
||||||
|
required int kind,
|
||||||
|
required List<List<String>> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue