Add NIP-17 gift-wrapped direct messages

Add outbound NIP-17 private messaging: build the kind-14 rumor -> kind-13
seal -> kind-1059 gift wrap chain on the existing NIP-44 primitives, wrapped
in NostrDmActor (WrapDirectMessage/GiftWrapped). Publishing is left to
NostrPublishActor.

Extract nostrEventId() from signNostrEvent so the unsigned rumor can compute
its id without a signature. Export the new actor and nip17 helpers. Add a
round-trip test (wrap -> unwrap) and a negative decryption test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-06-25 17:48:23 +03:00
parent 582b0505cb
commit f8ccd5d56b
5 changed files with 293 additions and 3 deletions

136
lib/src/nip17.dart Normal file
View file

@ -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<List<String>> rumorTags = const [],
}) {
final senderPubKey = bip340.getPublicKey(senderPrivKey);
// 1. Unsigned rumor has an id but no signature.
final rumorCreatedAt = _nowSeconds();
final rumorTagsWithRecipient = <List<String>>[
['p', recipientPubKey],
...rumorTags,
];
final rumorId = nostrEventId(
publicKey: senderPubKey,
createdAt: rumorCreatedAt,
kind: rumorKind,
tags: rumorTagsWithRecipient,
content: content,
);
final rumor = <String, dynamic>{
'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<String, dynamic> unwrapGift(
String giftWrapEvent,
String recipientPrivKey,
) {
final wrap = jsonDecode(giftWrapEvent) as Map<String, dynamic>;
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<String, dynamic>;
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<String, dynamic>;
}

View file

@ -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<List<String>> 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<DmMessage, DmResult> {
final String _privateKey;
NostrDmActor(String privateKey) : _privateKey = privateKey;
@override
Future<DmResult> 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,
),
),
),
};
}

View file

@ -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<List<String>> 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))

View file

@ -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';

View file

@ -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<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));
});
}