2026-06-25 17:48:23 +03:00
|
|
|
import 'dart:convert';
|
|
|
|
|
|
|
|
|
|
import 'package:bip340/bip340.dart' as bip340;
|
|
|
|
|
import 'package:swarm/swarm.dart';
|
2026-06-25 18:26:52 +03:00
|
|
|
// Low-level primitives (not part of the public surface) needed to hand-craft a
|
|
|
|
|
// spoofed gift wrap for the anti-spoofing test.
|
|
|
|
|
import 'package:swarm/src/nip44.dart';
|
|
|
|
|
import 'package:swarm/src/nostr_event.dart';
|
2026-06-25 17:48:23 +03:00
|
|
|
import 'package:test/test.dart';
|
|
|
|
|
|
|
|
|
|
const _senderPriv =
|
|
|
|
|
'0000000000000000000000000000000000000000000000000000000000000001';
|
|
|
|
|
const _recipientPriv =
|
|
|
|
|
'0000000000000000000000000000000000000000000000000000000000000002';
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
final senderPub = bip340.getPublicKey(_senderPriv);
|
|
|
|
|
final recipientPub = bip340.getPublicKey(_recipientPriv);
|
|
|
|
|
|
|
|
|
|
test('WrapDirectMessage produces a kind-1059 gift wrap', () async {
|
|
|
|
|
final actor = NostrDmActor(_senderPriv);
|
|
|
|
|
final result = await actor.handle(
|
|
|
|
|
WrapDirectMessage(recipientPubKey: recipientPub, content: 'hi'),
|
|
|
|
|
);
|
|
|
|
|
expect(result, isA<GiftWrapped>());
|
|
|
|
|
final event =
|
|
|
|
|
jsonDecode((result as GiftWrapped).event) as Map<String, dynamic>;
|
|
|
|
|
expect(event['kind'], kGiftWrapKind);
|
|
|
|
|
// The wrap is signed by an ephemeral key, never the sender.
|
|
|
|
|
expect(event['pubkey'], isNot(equals(senderPub)));
|
|
|
|
|
final pTags = (event['tags'] as List)
|
|
|
|
|
.cast<List>()
|
|
|
|
|
.where((t) => t.isNotEmpty && t[0] == 'p');
|
|
|
|
|
expect(pTags.single[1], equals(recipientPub));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('recipient unwraps the gift back to the original rumor', () async {
|
|
|
|
|
final actor = NostrDmActor(_senderPriv);
|
|
|
|
|
final result = await actor.handle(
|
|
|
|
|
WrapDirectMessage(
|
|
|
|
|
recipientPubKey: recipientPub,
|
|
|
|
|
content: 'check out this image',
|
|
|
|
|
rumorTags: const [
|
|
|
|
|
['imeta', 'url https://cdn.example/x.png', 'm image/png'],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
) as GiftWrapped;
|
|
|
|
|
|
|
|
|
|
final rumor = unwrapGift(result.event, _recipientPriv);
|
|
|
|
|
expect(rumor['content'], equals('check out this image'));
|
|
|
|
|
expect(rumor['kind'], equals(kDirectMessageKind));
|
|
|
|
|
expect(rumor['pubkey'], equals(senderPub));
|
|
|
|
|
|
|
|
|
|
final tags = (rumor['tags'] as List).cast<List>();
|
|
|
|
|
expect(
|
|
|
|
|
tags.any((t) => t.isNotEmpty && t[0] == 'p' && t[1] == recipientPub),
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
tags.any((t) => t.isNotEmpty && t[0] == 'imeta'),
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a different key cannot unwrap the gift', () async {
|
|
|
|
|
final actor = NostrDmActor(_senderPriv);
|
|
|
|
|
final result = await actor.handle(
|
|
|
|
|
WrapDirectMessage(recipientPubKey: recipientPub, content: 'secret'),
|
|
|
|
|
) as GiftWrapped;
|
|
|
|
|
|
|
|
|
|
const otherPriv =
|
|
|
|
|
'0000000000000000000000000000000000000000000000000000000000000003';
|
|
|
|
|
expect(() => unwrapGift(result.event, otherPriv), throwsA(anything));
|
|
|
|
|
});
|
2026-06-25 18:26:52 +03:00
|
|
|
|
|
|
|
|
test('UnwrapDirectMessage authenticates sender and returns contents',
|
|
|
|
|
() async {
|
|
|
|
|
final sender = NostrDmActor(_senderPriv);
|
|
|
|
|
final wrapped = await sender.handle(
|
|
|
|
|
WrapDirectMessage(
|
|
|
|
|
recipientPubKey: recipientPub,
|
|
|
|
|
content: 'see attached',
|
|
|
|
|
rumorTags: const [
|
|
|
|
|
['imeta', 'url https://cdn.example/x.png', 'm image/png'],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
) as GiftWrapped;
|
|
|
|
|
|
|
|
|
|
final recipient = NostrDmActor(_recipientPriv);
|
|
|
|
|
final unwrapped = await recipient.handle(
|
|
|
|
|
UnwrapDirectMessage(wrapped.event),
|
|
|
|
|
);
|
|
|
|
|
expect(unwrapped, isA<DirectMessageUnwrapped>());
|
|
|
|
|
final dm = unwrapped as DirectMessageUnwrapped;
|
|
|
|
|
expect(dm.senderPubKey, equals(senderPub));
|
|
|
|
|
expect(dm.content, equals('see attached'));
|
|
|
|
|
expect(
|
|
|
|
|
dm.tags.any((t) => t.isNotEmpty && t[0] == 'imeta'),
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a spoofed rumor author is rejected', () async {
|
|
|
|
|
// Hand-build a gift wrap whose inner rumor claims a different author than
|
|
|
|
|
// the seal signer — the seal is honestly signed but the rumor lies.
|
|
|
|
|
const attackerPriv =
|
|
|
|
|
'0000000000000000000000000000000000000000000000000000000000000009';
|
|
|
|
|
final attackerPub = bip340.getPublicKey(attackerPriv);
|
|
|
|
|
|
|
|
|
|
final rumor = {
|
|
|
|
|
'id': '0' * 64,
|
|
|
|
|
'pubkey': senderPub, // claims to be the trusted sender
|
|
|
|
|
'created_at': 1700000000,
|
|
|
|
|
'kind': kDirectMessageKind,
|
|
|
|
|
'tags': [
|
|
|
|
|
['p', recipientPub],
|
|
|
|
|
],
|
|
|
|
|
'content': 'totally legit',
|
|
|
|
|
};
|
|
|
|
|
final sealContent =
|
|
|
|
|
nip44Encrypt(jsonEncode(rumor), attackerPriv, recipientPub);
|
|
|
|
|
final seal = jsonDecode(
|
|
|
|
|
signNostrEvent(
|
|
|
|
|
privateKey: attackerPriv,
|
|
|
|
|
publicKey: attackerPub,
|
|
|
|
|
kind: kSealKind,
|
|
|
|
|
tags: const [],
|
|
|
|
|
content: sealContent,
|
|
|
|
|
),
|
|
|
|
|
) as Map<String, dynamic>;
|
|
|
|
|
final wrap = nip44Encrypt(jsonEncode(seal), attackerPriv, recipientPub);
|
|
|
|
|
final giftWrapEvent = signNostrEvent(
|
|
|
|
|
privateKey: attackerPriv,
|
|
|
|
|
publicKey: attackerPub,
|
|
|
|
|
kind: kGiftWrapKind,
|
|
|
|
|
tags: [
|
|
|
|
|
['p', recipientPub],
|
|
|
|
|
],
|
|
|
|
|
content: wrap,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
() => unwrapGift(giftWrapEvent, _recipientPriv),
|
|
|
|
|
throwsA(anything),
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-06-25 17:48:23 +03:00
|
|
|
}
|