mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
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:
co-authored by
Claude Opus 4.5
parent
045fcdd57e
commit
176610b211
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user