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>
This commit is contained in:
parent
f8ccd5d56b
commit
f69fcb661f
3 changed files with 162 additions and 3 deletions
|
|
@ -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<String, dynamic> 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<String>()).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<String, dynamic> unwrapGift(
|
||||
String giftWrapEvent,
|
||||
String recipientPrivKey,
|
||||
|
|
@ -127,10 +154,30 @@ Map<String, dynamic> 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<String, dynamic>;
|
||||
final rumor = jsonDecode(rumorJson) as Map<String, dynamic>;
|
||||
// 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<String>()).toList(),
|
||||
content: rumor['content'] as String,
|
||||
);
|
||||
if (rumorId != rumor['id']) {
|
||||
throw ArgumentError('Rumor id does not match its contents');
|
||||
}
|
||||
return rumor;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<List<String>> 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<DmMessage, DmResult> {
|
||||
|
|
@ -57,5 +79,19 @@ class NostrDmActor with Handler<DmMessage, DmResult> {
|
|||
),
|
||||
),
|
||||
),
|
||||
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<String>())
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue