From 176610b21187194aafdce284c96c35bb893fbbe5 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Thu, 15 Jan 2026 22:55:59 -0600 Subject: [PATCH] Add NIP-17 gift wrap service for crash reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Co-Authored-By: Claude Opus 4.5 --- lib/services/error_reporting_service.dart | 36 +++- lib/services/nip17_gift_wrap_service.dart | 229 ++++++++++++++++++++++ pubspec.lock | 28 +-- pubspec.yaml | 2 + 4 files changed, 280 insertions(+), 15 deletions(-) create mode 100644 lib/services/nip17_gift_wrap_service.dart diff --git a/lib/services/error_reporting_service.dart b/lib/services/error_reporting_service.dart index 7ff271d..6d99f07 100644 --- a/lib/services/error_reporting_service.dart +++ b/lib/services/error_reporting_service.dart @@ -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 sendCachedCrashReports( + List 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 diff --git a/lib/services/nip17_gift_wrap_service.dart b/lib/services/nip17_gift_wrap_service.dart new file mode 100644 index 0000000..949ebdc --- /dev/null +++ b/lib/services/nip17_gift_wrap_service.dart @@ -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 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 = >[ + ['p', recipientPubkey], + ]; + final rumorId = _computeEventIdFromParts( + senderPubkey, + rumorCreatedAt, + 14, + rumorTags, + content, + ); + + // Create rumor with proper field order matching NIP-01 event structure + final rumor = { + '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 = >[]; + final sealId = _computeEventIdFromParts( + senderPubkey, + sealCreatedAt, + 13, + sealTags, + encryptedRumor, + ); + final sealSig = await _signEventId(senderPrivateKey, sealId); + + // Create seal with proper field order + final signedSeal = { + '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 = >[ + ['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 = { + '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> tags, + String content, + ) { + // NIP-01: [0, , , , , ] + 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 _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 toMap() { + // Return the raw event data as-is + return event.toMap(); + } +} + +final nip17GiftWrapServiceProvider = Provider( + Nip17GiftWrapService.new, +); diff --git a/pubspec.lock b/pubspec.lock index 9528246..c181da8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 5853a3f..a51fc79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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