swarm/test/nostr_dm_actor_test.dart
randogoth f69fcb661f Authenticate and unwrap received gift-wrapped DMs
Harden unwrapGift for the receive side: verify the seal's BIP-340 signature
and recomputed id, enforce that the rumor's author matches the seal signer
(NIP-59 anti-spoofing), and check the rumor id against its contents. The
rumor is unsigned by design, so sender authentication rests on the seal.

Add UnwrapDirectMessage to NostrDmActor, returning the authenticated sender,
content, timestamp, and tags. Cover both with tests: an unwrap round-trip and
a rejection of a wrap whose inner rumor spoofs a different author.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:26:52 +03:00

147 lines
4.8 KiB
Dart

import 'dart:convert';
import 'package:bip340/bip340.dart' as bip340;
import 'package:swarm/swarm.dart';
// 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';
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));
});
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),
);
});
}