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
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