Add NIP-17 gift wrap service for crash reports

- Implement NIP-59 gift wrap protocol (rumor → seal → gift wrap)
- Add NIP-44 encrypted message delivery with ephemeral keys
- Support NIP-40 expiration tags for auto-deletion
- Update error_reporting_service to use gift wrap for cached crash reports

Part of #268

Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
alltheseas
2026-01-15 22:55:59 -06:00
co-authored by Claude Opus 4.5
parent 045fcdd57e
commit 176610b211
4 changed files with 280 additions and 15 deletions
+35 -1
View File
@@ -3,9 +3,12 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/services/crash_report_cache_service.dart';
import 'package:zapstore/services/nip17_gift_wrap_service.dart';
import 'package:zapstore/utils/extensions.dart';
/// Service for reporting errors via NIP-44 encrypted DMs to Zapstore team.
/// Service for reporting errors via NIP-17 gift-wrapped messages to Zapstore team.
///
/// Generates an ephemeral signer per report so errors can be sent even when
/// the user is not signed in.
@@ -100,6 +103,37 @@ class ErrorReportingService {
return buffer.toString();
}
/// Send cached crash reports with user consent.
///
/// Uses NIP-17 gift wrap for enhanced privacy:
/// - Ephemeral keys hide sender identity on relays
/// - NIP-44 encryption protects message content
/// - NIP-40 expiration tags ensure auto-deletion after 30 days
///
/// [userComment] is an optional message from the user describing what happened.
Future<void> sendCachedCrashReports(
List<CrashReport> crashes, {
String? userComment,
}) async {
final giftWrapService = ref.read(nip17GiftWrapServiceProvider);
for (final crash in crashes) {
try {
// Format crash report with optional user comment
final report = crash.toReportString(userComment: userComment);
// Send using NIP-17 gift wrap with 30-day expiration
await giftWrapService.sendGiftWrappedMessage(
content: report,
recipientPubkey: kCrashReportPubkey,
expirationDays: 30,
);
} catch (e) {
// Silently fail individual reports - continue with others
}
}
}
}
/// Provider for the error reporting service
+229
View File
@@ -0,0 +1,229 @@
import 'dart:convert';
import 'dart:math';
import 'package:bip340/bip340.dart' as bip340;
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:models/models.dart';
/// NIP-17 Gift Wrap service for private crash report delivery.
///
/// Implements the NIP-59 gift wrap protocol:
/// 1. Rumor (kind 14) - unsigned message
/// 2. Seal (kind 13) - encrypted rumor, signed by sender
/// 3. Gift Wrap (kind 1059) - encrypted seal, signed by ephemeral key
///
/// This provides enhanced privacy by hiding the sender's identity
/// through an ephemeral wrapper key.
class Nip17GiftWrapService {
Nip17GiftWrapService(this.ref);
final Ref ref;
static const _twoDaysInSeconds = 2 * 24 * 60 * 60;
/// Create and publish a NIP-17 gift-wrapped message.
///
/// [content] - The message content
/// [recipientPubkey] - Recipient's public key (hex)
/// [expirationDays] - Optional expiration in days (NIP-40)
Future<void> sendGiftWrappedMessage({
required String content,
required String recipientPubkey,
int? expirationDays,
}) async {
// Create ephemeral sender signer for the seal
final senderPrivateKey = Utils.generateRandomHex64();
final senderSigner = Bip340PrivateKeySigner(senderPrivateKey, ref);
await senderSigner.signIn(setAsActive: false, registerSigner: false);
final senderPubkey = senderSigner.pubkey;
// Create ephemeral signer for gift wrap
final ephemeralPrivateKey = Utils.generateRandomHex64();
final ephemeralSigner = Bip340PrivateKeySigner(ephemeralPrivateKey, ref);
await ephemeralSigner.signIn(setAsActive: false, registerSigner: false);
final ephemeralPubkey = ephemeralSigner.pubkey;
// 1. Create rumor (kind 14, unsigned)
// NIP-17 specifies the rumor should have sig: '' (empty string)
final rumorCreatedAt = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final rumorTags = <List<String>>[
['p', recipientPubkey],
];
final rumorId = _computeEventIdFromParts(
senderPubkey,
rumorCreatedAt,
14,
rumorTags,
content,
);
// Create rumor with proper field order matching NIP-01 event structure
final rumor = <String, dynamic>{
'id': rumorId,
'pubkey': senderPubkey,
'created_at': rumorCreatedAt,
'kind': 14,
'tags': rumorTags,
'content': content,
'sig': '', // NIP-17: rumor's sig is empty string
};
final rumorJson = jsonEncode(rumor);
// 2. Create seal (kind 13)
// Encrypt the rumor JSON with NIP-44 to the recipient
final encryptedRumor = await senderSigner.nip44Encrypt(
rumorJson,
recipientPubkey,
);
final sealCreatedAt = _randomizeTimestamp(rumorCreatedAt);
final sealTags = <List<String>>[];
final sealId = _computeEventIdFromParts(
senderPubkey,
sealCreatedAt,
13,
sealTags,
encryptedRumor,
);
final sealSig = await _signEventId(senderPrivateKey, sealId);
// Create seal with proper field order
final signedSeal = <String, dynamic>{
'id': sealId,
'pubkey': senderPubkey,
'created_at': sealCreatedAt,
'kind': 13,
'tags': sealTags,
'content': encryptedRumor,
'sig': sealSig,
};
final sealJson = jsonEncode(signedSeal);
// 3. Create gift wrap (kind 1059)
// Encrypt the seal JSON with NIP-44 using ephemeral key
final encryptedSeal = await ephemeralSigner.nip44Encrypt(
sealJson,
recipientPubkey,
);
final giftWrapCreatedAt = _randomizeTimestamp(rumorCreatedAt);
final giftWrapTags = <List<String>>[
['p', recipientPubkey],
];
// Add expiration to gift wrap if specified (NIP-40)
if (expirationDays != null) {
final expiration = DateTime.now()
.add(Duration(days: expirationDays))
.millisecondsSinceEpoch ~/
1000;
giftWrapTags.add(['expiration', expiration.toString()]);
}
final giftWrapId = _computeEventIdFromParts(
ephemeralPubkey,
giftWrapCreatedAt,
1059,
giftWrapTags,
encryptedSeal,
);
final giftWrapSig = await _signEventId(ephemeralPrivateKey, giftWrapId);
// Create gift wrap with proper field order
final signedGiftWrap = <String, dynamic>{
'id': giftWrapId,
'pubkey': ephemeralPubkey,
'created_at': giftWrapCreatedAt,
'kind': 1059,
'tags': giftWrapTags,
'content': encryptedSeal,
'sig': giftWrapSig,
};
// Debug: Print the gift wrap event structure
if (kDebugMode) {
debugPrint('=== NIP-17 Gift Wrap Debug ===');
debugPrint('Rumor (kind 14): ${jsonEncode(rumor)}');
debugPrint('Seal (kind 13): ${jsonEncode(signedSeal)}');
debugPrint('Gift Wrap (kind 1059): ${jsonEncode(signedGiftWrap)}');
}
// Publish using RawGiftWrap wrapper
final rawEvent = RawGiftWrap.fromMap(signedGiftWrap, ref);
// Debug: Print what toMap returns
if (kDebugMode) {
debugPrint('RawGiftWrap.toMap(): ${jsonEncode(rawEvent.toMap())}');
debugPrint('RawGiftWrap.event.kind: ${rawEvent.event.kind}');
}
await ref.read(storageNotifierProvider.notifier).publish(
{rawEvent},
source: const RemoteSource(relays: 'social'),
);
}
/// Compute the event ID (sha256 of serialized event data).
/// Matches NIP-01 serialization: [0, pubkey, created_at, kind, tags, content]
String _computeEventIdFromParts(
String pubkey,
int createdAt,
int kind,
List<List<String>> tags,
String content,
) {
// NIP-01: [0, <pubkey lowercase hex>, <created_at>, <kind>, <tags>, <content>]
final eventData = [
0,
pubkey.toLowerCase(),
createdAt,
kind,
tags,
content,
];
final serialized = jsonEncode(eventData);
final hashBytes = sha256.convert(utf8.encode(serialized));
// Return lowercase hex string (matching hex.encode behavior)
return hashBytes.bytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
/// Sign an event ID using BIP-340 Schnorr signature.
Future<String> _signEventId(String privateKeyHex, String eventId) async {
// Generate 32 random bytes for aux (matches 0xchat's generate64RandomHexChars)
final random = Random.secure();
final auxBytes =
List.generate(32, (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'));
final aux = auxBytes.join();
return bip340.sign(privateKeyHex, eventId, aux);
}
/// Randomize timestamp by subtracting 0-2 days for privacy.
int _randomizeTimestamp(int baseTimestamp) {
final random = Random.secure();
final offset = random.nextInt(_twoDaysInSeconds);
return baseTimestamp - offset;
}
}
/// Raw gift wrap event wrapper for publishing.
///
/// This extends Note (kind 1) but overrides toMap to output kind 1059.
/// This is a workaround to publish raw events through the models framework.
class RawGiftWrap extends Note {
RawGiftWrap.fromMap(super.map, super.ref) : super.fromMap();
@override
Map<String, dynamic> toMap() {
// Return the raw event data as-is
return event.toMap();
}
}
final nip17GiftWrapServiceProvider = Provider<Nip17GiftWrapService>(
Nip17GiftWrapService.new,
);
+14 -14
View File
@@ -107,7 +107,7 @@ packages:
source: hosted
version: "0.2.2"
bip340:
dependency: transitive
dependency: "direct main"
description:
name: bip340
sha256: b7bcd70a860e605046006adaa72bc4f7453f4d31d7ba74a4ad9d5de387a0fc0b
@@ -227,7 +227,7 @@ packages:
source: hosted
version: "0.3.5+1"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -589,26 +589,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.9"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.9"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@@ -669,10 +669,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -1157,10 +1157,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.4"
version: "0.7.7"
timezone:
dependency: transitive
description:
@@ -1277,10 +1277,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
+2
View File
@@ -17,6 +17,8 @@ dependencies:
flutter:
sdk: flutter
bip340: ^0.3.0
crypto: ^3.0.0
flutter_riverpod: ^2.6.1
flutter_hooks: ^0.21.2
path_provider: ^2.1.5