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:
parent
582b0505cb
commit
f8ccd5d56b
5 changed files with 293 additions and 3 deletions
136
lib/src/nip17.dart
Normal file
136
lib/src/nip17.dart
Normal 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>;
|
||||
}
|
||||
61
lib/src/nostr_dm_actor.dart
Normal file
61
lib/src/nostr_dm_actor.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue