diff --git a/lib/src/nip17.dart b/lib/src/nip17.dart new file mode 100644 index 0000000..42aeedb --- /dev/null +++ b/lib/src/nip17.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:bip340/bip340.dart' as bip340; + +import 'nip44.dart'; +import 'nostr_event.dart'; + +/// NIP-17 private direct messages over NIP-59 gift wraps. +/// +/// A message is built in three layers: +/// 1. an unsigned **rumor** (the real event, here kind 14) carrying the +/// recipient `p` tag; +/// 2. a **seal** (kind 13) signed by the sender, whose content is the rumor +/// NIP-44-encrypted to the recipient; +/// 3. a **gift wrap** (kind 1059) signed by a throwaway ephemeral key, whose +/// content is the seal NIP-44-encrypted to the recipient. +/// +/// Only the gift wrap is published. The recipient's pubkey appears just in the +/// gift wrap's `p` tag; the sender's identity is hidden until the recipient +/// decrypts the seal. Seal and wrap timestamps are randomized into the past +/// (up to two days) so they leak nothing about send time. + +const int kSealKind = 13; +const int kGiftWrapKind = 1059; +const int kDirectMessageKind = 14; + +const int _twoDaysSeconds = 2 * 24 * 60 * 60; + +final Random _rng = Random.secure(); + +int _nowSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000; + +/// A timestamp randomly displaced up to two days into the past (NIP-59). +int _randomPastTimestamp() => _nowSeconds() - _rng.nextInt(_twoDaysSeconds); + +String _randomPrivateKey() { + final bytes = List.generate(32, (_) => _rng.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +} + +/// Builds a NIP-59 gift wrap (kind 1059) carrying a NIP-17 direct message. +/// +/// [rumorKind] defaults to kind 14 (the NIP-17 chat message). [rumorTags] are +/// merged with the recipient `p` tag. Returns the signed gift-wrap event as a +/// JSON string ready to publish. +String giftWrap({ + required String senderPrivKey, + required String recipientPubKey, + required String content, + int rumorKind = kDirectMessageKind, + List> rumorTags = const [], +}) { + final senderPubKey = bip340.getPublicKey(senderPrivKey); + + // 1. Unsigned rumor — has an id but no signature. + final rumorCreatedAt = _nowSeconds(); + final rumorTagsWithRecipient = >[ + ['p', recipientPubKey], + ...rumorTags, + ]; + final rumorId = nostrEventId( + publicKey: senderPubKey, + createdAt: rumorCreatedAt, + kind: rumorKind, + tags: rumorTagsWithRecipient, + content: content, + ); + final rumor = { + 'id': rumorId, + 'pubkey': senderPubKey, + 'created_at': rumorCreatedAt, + 'kind': rumorKind, + 'tags': rumorTagsWithRecipient, + 'content': content, + }; + + // 2. Seal (kind 13) signed by the sender; content = rumor encrypted to + // the recipient. No tags, randomized timestamp. + final sealContent = + nip44Encrypt(jsonEncode(rumor), senderPrivKey, recipientPubKey); + final seal = signNostrEvent( + privateKey: senderPrivKey, + publicKey: senderPubKey, + kind: kSealKind, + tags: const [], + content: sealContent, + createdAt: _randomPastTimestamp(), + ); + + // 3. Gift wrap (kind 1059) signed by an ephemeral key; content = seal + // encrypted to the recipient, `p` tag addresses the recipient. + final ephemeralPrivKey = _randomPrivateKey(); + final ephemeralPubKey = bip340.getPublicKey(ephemeralPrivKey); + final wrapContent = nip44Encrypt(seal, ephemeralPrivKey, recipientPubKey); + return signNostrEvent( + privateKey: ephemeralPrivKey, + publicKey: ephemeralPubKey, + kind: kGiftWrapKind, + tags: [ + ['p', recipientPubKey], + ], + content: wrapContent, + createdAt: _randomPastTimestamp(), + ); +} + +/// 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. +/// +/// Provided so receivers (a later version) and tests can reverse [giftWrap]. +Map unwrapGift( + String giftWrapEvent, + String recipientPrivKey, +) { + final wrap = jsonDecode(giftWrapEvent) as Map; + if (wrap['kind'] != kGiftWrapKind) { + throw ArgumentError('Not a gift wrap (kind ${wrap['kind']})'); + } + final sealJson = nip44Decrypt( + wrap['content'] as String, + recipientPrivKey, + wrap['pubkey'] as String, + ); + final seal = jsonDecode(sealJson) as Map; + if (seal['kind'] != kSealKind) { + throw ArgumentError('Not a seal (kind ${seal['kind']})'); + } + final rumorJson = nip44Decrypt( + seal['content'] as String, + recipientPrivKey, + seal['pubkey'] as String, + ); + return jsonDecode(rumorJson) as Map; +} diff --git a/lib/src/nostr_dm_actor.dart b/lib/src/nostr_dm_actor.dart new file mode 100644 index 0000000..49c9d2c --- /dev/null +++ b/lib/src/nostr_dm_actor.dart @@ -0,0 +1,61 @@ +import 'package:actors/actors.dart'; + +import 'nip17.dart'; + +sealed class DmMessage { + const DmMessage(); +} + +/// Wrap [content] (with optional [rumorTags]) as a NIP-17 direct message +/// gift-wrapped to [recipientPubKey]. The result is a signed kind-1059 event +/// ready to publish via `NostrPublishActor`. +final class WrapDirectMessage extends DmMessage { + final String recipientPubKey; + final String content; + final int rumorKind; + final List> rumorTags; + const WrapDirectMessage({ + required this.recipientPubKey, + required this.content, + this.rumorKind = kDirectMessageKind, + this.rumorTags = const [], + }); +} + +sealed class DmResult { + const DmResult(); +} + +final class GiftWrapped extends DmResult { + final String event; + const GiftWrapped(this.event); +} + +/// Builds NIP-17 gift-wrapped direct messages with the holder's private key. +/// Construction only — publishing is left to `NostrPublishActor`. +class NostrDmActor with Handler { + final String _privateKey; + + NostrDmActor(String privateKey) : _privateKey = privateKey; + + @override + Future handle(DmMessage message) => switch (message) { + WrapDirectMessage( + :final recipientPubKey, + :final content, + :final rumorKind, + :final rumorTags, + ) => + Future.value( + GiftWrapped( + giftWrap( + senderPrivKey: _privateKey, + recipientPubKey: recipientPubKey, + content: content, + rumorKind: rumorKind, + rumorTags: rumorTags, + ), + ), + ), + }; +} diff --git a/lib/src/nostr_event.dart b/lib/src/nostr_event.dart index 621c745..6ff1b81 100644 --- a/lib/src/nostr_event.dart +++ b/lib/src/nostr_event.dart @@ -4,6 +4,22 @@ import 'dart:math'; import 'package:bip340/bip340.dart' as bip340; import 'package:crypto/crypto.dart'; +/// Computes the Nostr event id: the lowercase hex SHA-256 of the canonical +/// serialization `[0, pubkey, created_at, kind, tags, content]`. Shared by +/// [signNostrEvent] and by unsigned events such as NIP-59 rumors, which carry +/// an id but no signature. +String nostrEventId({ + required String publicKey, + required int createdAt, + required int kind, + required List> tags, + required String content, +}) { + final ser = jsonEncode([0, publicKey, createdAt, kind, tags, content]); + final idBytes = sha256.convert(utf8.encode(ser)).bytes; + return idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +} + String signNostrEvent({ required String privateKey, required String publicKey, @@ -13,9 +29,13 @@ String signNostrEvent({ int? createdAt, }) { final ts = createdAt ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; - final ser = jsonEncode([0, publicKey, ts, kind, tags, content]); - final idBytes = sha256.convert(utf8.encode(ser)).bytes; - final id = idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final id = nostrEventId( + publicKey: publicKey, + createdAt: ts, + kind: kind, + tags: tags, + content: content, + ); final rng = Random.secure(); final aux = List.generate(32, (_) => rng.nextInt(256)) diff --git a/lib/swarm.dart b/lib/swarm.dart index 7ef71c6..650ad00 100644 --- a/lib/swarm.dart +++ b/lib/swarm.dart @@ -10,3 +10,5 @@ export 'src/nostr_account_actor.dart'; export 'src/nip46_signer_actor.dart'; export 'src/nip19.dart'; export 'src/nip05.dart'; +export 'src/nip17.dart'; +export 'src/nostr_dm_actor.dart'; diff --git a/test/nostr_dm_actor_test.dart b/test/nostr_dm_actor_test.dart new file mode 100644 index 0000000..c6b4087 --- /dev/null +++ b/test/nostr_dm_actor_test.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; + +import 'package:bip340/bip340.dart' as bip340; +import 'package:swarm/swarm.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()); + final event = + jsonDecode((result as GiftWrapped).event) as Map; + 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() + .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(); + 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)); + }); +}