diff --git a/lib/src/nip17.dart b/lib/src/nip17.dart index 42aeedb..00a745b 100644 --- a/lib/src/nip17.dart +++ b/lib/src/nip17.dart @@ -6,6 +6,29 @@ import 'package:bip340/bip340.dart' as bip340; import 'nip44.dart'; import 'nostr_event.dart'; +/// Recomputes a signed event's id from its fields and checks the BIP-340 +/// signature against its pubkey. Throws if either is inconsistent. +void _verifySignedEvent(Map event) { + final id = nostrEventId( + publicKey: event['pubkey'] as String, + createdAt: event['created_at'] as int, + kind: event['kind'] as int, + tags: + (event['tags'] as List).map((t) => (t as List).cast()).toList(), + content: event['content'] as String, + ); + if (id != event['id']) { + throw ArgumentError('Event id does not match its contents'); + } + if (!bip340.verify( + event['pubkey'] as String, + id, + event['sig'] as String, + )) { + throw ArgumentError('Event signature is invalid'); + } +} + /// NIP-17 private direct messages over NIP-59 gift wraps. /// /// A message is built in three layers: @@ -107,9 +130,13 @@ String giftWrap({ /// Unwraps a gift wrap (kind 1059) received by the holder of [recipientPrivKey]. /// Returns the inner rumor as a decoded map (`id`, `pubkey`, `kind`, `tags`, -/// `content`, …). Throws if decryption or the kind chain is invalid. +/// `content`, …) whose `pubkey` is the authenticated sender. /// -/// Provided so receivers (a later version) and tests can reverse [giftWrap]. +/// Throws if decryption fails, the kind chain is wrong, the seal's signature +/// is invalid, or the rumor's author does not match the seal's signer. The +/// rumor itself is unsigned by design (NIP-59), so authentication rests on the +/// seal: we verify the seal's signature and that it claims the same author as +/// the rumor, which prevents a third party from forging the sender. Map unwrapGift( String giftWrapEvent, String recipientPrivKey, @@ -127,10 +154,30 @@ Map unwrapGift( if (seal['kind'] != kSealKind) { throw ArgumentError('Not a seal (kind ${seal['kind']})'); } + // Authenticate the sender: the seal is signed by the claimed author. + _verifySignedEvent(seal); + final rumorJson = nip44Decrypt( seal['content'] as String, recipientPrivKey, seal['pubkey'] as String, ); - return jsonDecode(rumorJson) as Map; + final rumor = jsonDecode(rumorJson) as Map; + // Anti-spoofing: the rumor's author must be the one who signed the seal. + if (rumor['pubkey'] != seal['pubkey']) { + throw ArgumentError('Rumor author does not match seal signer'); + } + // Integrity: the unsigned rumor's id must match its own contents. + final rumorId = nostrEventId( + publicKey: rumor['pubkey'] as String, + createdAt: rumor['created_at'] as int, + kind: rumor['kind'] as int, + tags: + (rumor['tags'] as List).map((t) => (t as List).cast()).toList(), + content: rumor['content'] as String, + ); + if (rumorId != rumor['id']) { + throw ArgumentError('Rumor id does not match its contents'); + } + return rumor; } diff --git a/lib/src/nostr_dm_actor.dart b/lib/src/nostr_dm_actor.dart index 49c9d2c..160f611 100644 --- a/lib/src/nostr_dm_actor.dart +++ b/lib/src/nostr_dm_actor.dart @@ -22,6 +22,13 @@ final class WrapDirectMessage extends DmMessage { }); } +/// Unwrap a received gift wrap (kind-1059 event JSON) addressed to the holder. +/// Decrypts and authenticates the sender; throws if invalid (see [unwrapGift]). +final class UnwrapDirectMessage extends DmMessage { + final String giftWrapEvent; + const UnwrapDirectMessage(this.giftWrapEvent); +} + sealed class DmResult { const DmResult(); } @@ -31,6 +38,21 @@ final class GiftWrapped extends DmResult { const GiftWrapped(this.event); } +/// The authenticated contents of an unwrapped direct message. [senderPubKey] is +/// verified (it signed the seal); the rumor itself is unsigned by design. +final class DirectMessageUnwrapped extends DmResult { + final String senderPubKey; + final String content; + final int createdAt; + final List> tags; + const DirectMessageUnwrapped({ + required this.senderPubKey, + required this.content, + required this.createdAt, + required this.tags, + }); +} + /// Builds NIP-17 gift-wrapped direct messages with the holder's private key. /// Construction only — publishing is left to `NostrPublishActor`. class NostrDmActor with Handler { @@ -57,5 +79,19 @@ class NostrDmActor with Handler { ), ), ), + UnwrapDirectMessage(:final giftWrapEvent) => + Future.value(_unwrap(giftWrapEvent)), }; + + DirectMessageUnwrapped _unwrap(String giftWrapEvent) { + final rumor = unwrapGift(giftWrapEvent, _privateKey); + return DirectMessageUnwrapped( + senderPubKey: rumor['pubkey'] as String, + content: rumor['content'] as String, + createdAt: rumor['created_at'] as int, + tags: (rumor['tags'] as List) + .map((t) => (t as List).cast()) + .toList(), + ); + } } diff --git a/test/nostr_dm_actor_test.dart b/test/nostr_dm_actor_test.dart index c6b4087..1d4d8a5 100644 --- a/test/nostr_dm_actor_test.dart +++ b/test/nostr_dm_actor_test.dart @@ -2,6 +2,10 @@ 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 = @@ -68,4 +72,76 @@ void main() { '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()); + 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; + 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), + ); + }); }