diff --git a/.gitignore b/.gitignore index 3a08e55..c03b4dd 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ node_modules/ # Test vectors test-vectors/node_modules/ test-vectors/package-lock.json + +# Beads (local issue tracking) +.beads/ diff --git a/AGENTS.md b/AGENTS.md index e9da83f..f432285 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,65 @@ id = sha256(json([0, pubkey, created_at, kind, tags, content])) Returns lowercase hex string (64 characters). +## Lessons Learned + +### CHK Encryption Compatibility (Critical) + +**Problem**: All SDKs implemented CHK (Content Hash Key) encryption differently from the Rust reference implementation, causing complete decryption failure. + +**Root Cause**: Each SDK used its own interpretation of "encrypt with content hash": +- Some used AES-256-CBC with random IV +- Others omitted HKDF key derivation +- Ciphertext format varied (IV position, tag handling) + +**The Correct Algorithm** (must match `hashtree-core` exactly): + +``` +1. content_hash = SHA256(plaintext) +2. key = HKDF-SHA256( + ikm: content_hash, + salt: "hashtree-chk", + info: "encryption-key", + length: 32 + ) +3. ciphertext = AES-256-GCM( + key: key, + nonce: 12 zero bytes, + plaintext: data + ) +4. output = [ciphertext][16-byte auth tag] +``` + +**Why each component matters**: + +| Component | Purpose | If Wrong | +|-----------|---------|----------| +| HKDF | Derives encryption key from content hash | Key mismatch → decryption fails | +| Salt `"hashtree-chk"` | Domain separation | Different key → decryption fails | +| Info `"encryption-key"` | Key purpose binding | Different key → decryption fails | +| Zero nonce | Safe for CHK (same key = same content) | Different ciphertext → verification fails | +| AES-GCM | Authenticated encryption | Different algorithm → decryption fails | + +**Why zero nonce is safe**: CHK is convergent encryption - the same plaintext always produces the same key. Since the key is deterministic, using a random nonce would make ciphertext non-deterministic, breaking content-addressable storage. Zero nonce is safe because the key is never reused with different content. + +**Verification checklist for new implementations**: +1. Generate test vector in Rust: `cargo test chunking -- --nocapture` +2. Encrypt same plaintext in your SDK +3. Compare: content hash, derived key, ciphertext must be byte-identical +4. Decrypt Rust ciphertext in your SDK (and vice versa) + +**Platform-specific libraries**: + +| Platform | HKDF | AES-GCM | +|----------|------|---------| +| Rust | `hashtree-core` | (built-in) | +| Dart | `pointycastle` HKDFKeyDerivator | `pointycastle` GCMBlockCipher | +| Kotlin | Manual HMAC-SHA256 | `javax.crypto` AES/GCM/NoPadding | +| Go | `golang.org/x/crypto/hkdf` | `crypto/cipher` NewGCM | +| Python | `cryptography` HKDF | `cryptography` AESGCM | +| TypeScript (Node) | `crypto` hkdfSync | `crypto` aes-256-gcm | +| TypeScript (RN) | `@noble/hashes/hkdf` | `@noble/ciphers/aes` gcm | + ## Testing ### Unit Tests diff --git a/README.md b/README.md index faf0241..80d767f 100644 --- a/README.md +++ b/README.md @@ -34,27 +34,19 @@ Crash → Cache locally → App restart → Show consent dialog → User approve All SDKs use the same default relay list, chosen for reliability: -| Relay | Max Event Size | Max WebSocket | Notes | -|-------|----------------|---------------|-------| -| `wss://relay.damus.io` | 64 KB | 128 KB | strfry defaults | -| `wss://relay.primal.net` | 64 KB | 128 KB | strfry defaults | -| `wss://nos.lol` | 128 KB | 128 KB | Fallback relay | +| Relay | Notes | +|-------|-------| +| `wss://relay.damus.io` | strfry defaults | +| `wss://relay.primal.net` | strfry defaults | +| `wss://nos.lol` | Fallback relay | -**Note:** Most relays use strfry defaults (64 KB event size, 128 KB websocket payload). The practical limit for crash reports is ~60 KB to allow for gift-wrap envelope overhead. +**Note:** Most relays enforce a 64 KB event size limit (strfry default). Keep compressed payloads under **60 KB** to allow for gift-wrap envelope overhead. You can override these defaults via the `relays` configuration option in each SDK. ## Size Limits & Compression -Crash reports are subject to relay message size limits (see [NIP-11](https://github.com/nostr-protocol/nips/blob/master/11.md) `max_message_length`). - -| Relay Limit | Compatibility | -|-------------|---------------| -| 64 KB | ~99% of relays | -| 128 KB | ~90% of relays | -| 512 KB+ | Major relays only | - -**Practical limit:** Keep compressed payloads under **60 KB** for universal delivery (allows ~500 bytes for gift-wrap envelope overhead). +Crash reports are subject to relay message size limits (see [NIP-11](https://github.com/nostr-protocol/nips/blob/master/11.md) `max_message_length`). Most relays enforce a **64 KB** limit. | Payload Size | Behavior | |--------------|----------| @@ -85,7 +77,7 @@ Gzip typically achieves **70-90% reduction** on stack traces due to their repeti | 50 KB | ~5-10 KB | ~80-90% | | 200 KB | ~20-40 KB | ~80-85% | -With gzip compression (70-90% reduction), most crash reports fit well within the 64 KB strfry default limit. For maximum compatibility, keep compressed payloads under 60 KB. +With gzip compression, most crash reports fit well within the 64 KB limit. ## Nostr Protocol @@ -102,27 +94,64 @@ Per NIP-17, rumors (kind 14) must include: - `id` - SHA256 hash of `[0, pubkey, created_at, kind, tags, content]` - `sig: ""` - Empty string (not omitted) -Some clients (e.g., 0xchat) reject messages missing these fields. - ## Shared Test Vectors The [`test-vectors/`](test-vectors/) directory contains JSON test cases for NIP-17. All platform implementations should validate against these vectors. ## Symbolication -Release builds typically use code obfuscation/minification, producing stack traces with mangled names and memory addresses instead of readable function names and line numbers. +The Rust receiver includes built-in symbolication for 7 platforms: + +| Platform | Mapping File | Notes | +|----------|--------------|-------| +| Android | `mapping.txt` | ProGuard/R8 with full line-range support | +| Electron/JS | `*.js.map` | Source map v3 | +| Flutter | `*.symbols` | Via `flutter symbolize` or direct parsing | +| Rust | Backtrace | Debug builds include source locations | +| Go | Goroutine stacks | Symbol tables usually embedded | +| Python | Tracebacks | Source file mapping | +| React Native | `*.bundle.map` | Hermes bytecode + JS source maps | + +### CLI Usage + +```bash +# Symbolicate a stack trace +bugstr symbolicate --platform android --input crash.txt --mappings ./mappings \ + --app-id com.example.app --version 1.0.0 + +# Output formats: pretty (default) or json +bugstr symbolicate --platform android --input crash.txt --format json +``` + +### Web API -**Current status:** Symbolication tooling is not yet implemented. Crash reports contain raw stack traces as captured. +```bash +# Start server with symbolication enabled +bugstr serve --mappings ./mappings + +# POST to symbolicate endpoint +curl -X POST http://localhost:3000/api/symbolicate \ + -H "Content-Type: application/json" \ + -d '{"platform":"android","stack_trace":"...","app_id":"com.example","version":"1.0.0"}' +``` -**Planned approach:** -- Store mapping files (ProGuard, dSYM, sourcemaps) locally or in your CI -- Use platform-specific tools to symbolicate: - - **Android**: `retrace` with ProGuard mapping - - **iOS/macOS**: `atos` or `symbolicatecrash` with dSYM - - **JavaScript**: Source map support in browser devtools - - **Flutter**: `flutter symbolize` with app symbols +### Mapping File Organization + +``` +mappings/ + android/ + com.example.app/ + 1.0.0/mapping.txt + 1.1.0/mapping.txt + electron/ + my-app/ + 1.0.0/main.js.map + flutter/ + com.example.app/ + 1.0.0/app.android-arm64.symbols +``` -Contributions welcome for automated symbolication in the receiver CLI/WebUI. +The receiver automatically falls back to the newest available version if an exact version match isn't found. ## Contributing diff --git a/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Chunking.kt b/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Chunking.kt new file mode 100644 index 0000000..7186d43 --- /dev/null +++ b/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Chunking.kt @@ -0,0 +1,229 @@ +package com.bugstr.nostr.crypto + +import android.util.Base64 +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * CHK (Content Hash Key) chunking for large crash reports. + * + * Implements hashtree-core compatible encryption where: + * - Data is split into fixed-size chunks + * - Each chunk's content hash is derived via HKDF to get the encryption key + * - AES-256-GCM with zero nonce encrypts each chunk + * - A root hash is computed from all chunk hashes + * - Only the manifest (with root hash) needs to be encrypted via NIP-17 + * - Chunks are public but opaque without the root hash + * + * **CRITICAL**: Must match hashtree-core crypto exactly: + * - Key derivation: HKDF-SHA256(content_hash, salt="hashtree-chk", info="encryption-key") + * - Cipher: AES-256-GCM with 12-byte zero nonce + * - Format: [ciphertext][16-byte auth tag] + */ +object Chunking { + private const val AES_GCM_ALGORITHM = "AES/GCM/NoPadding" + private const val KEY_ALGORITHM = "AES" + private const val HMAC_ALGORITHM = "HmacSHA256" + + /** HKDF salt for CHK derivation (must match hashtree-core) */ + private val CHK_SALT = "hashtree-chk".toByteArray(Charsets.UTF_8) + + /** HKDF info for key derivation (must match hashtree-core) */ + private val CHK_INFO = "encryption-key".toByteArray(Charsets.UTF_8) + + /** Nonce size for AES-GCM (96 bits) */ + private const val NONCE_SIZE = 12 + + /** Auth tag size for AES-GCM (128 bits) */ + private const val TAG_SIZE_BITS = 128 + + /** + * Result of chunking a payload. + */ + data class ChunkingResult( + val rootHash: String, + val totalSize: Int, + val chunks: List, + ) + + /** + * Encrypted chunk data before publishing. + */ + data class ChunkData( + val index: Int, + val hash: ByteArray, + val encrypted: ByteArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ChunkData) return false + return index == other.index && hash.contentEquals(other.hash) + } + + override fun hashCode(): Int = 31 * index + hash.contentHashCode() + } + + /** + * HKDF-Extract: PRK = HMAC-SHA256(salt, IKM) + */ + private fun hkdfExtract(salt: ByteArray, ikm: ByteArray): ByteArray { + val mac = Mac.getInstance(HMAC_ALGORITHM) + mac.init(SecretKeySpec(salt, HMAC_ALGORITHM)) + return mac.doFinal(ikm) + } + + /** + * HKDF-Expand: OKM = HMAC-based expansion + */ + private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray { + val mac = Mac.getInstance(HMAC_ALGORITHM) + mac.init(SecretKeySpec(prk, HMAC_ALGORITHM)) + + val hashLen = 32 // SHA-256 output length + val n = (length + hashLen - 1) / hashLen + val okm = ByteArray(length) + var t = ByteArray(0) + var okmOffset = 0 + + for (i in 1..n) { + mac.reset() + mac.update(t) + mac.update(info) + mac.update(i.toByte()) + t = mac.doFinal() + + val copyLen = minOf(hashLen, length - okmOffset) + System.arraycopy(t, 0, okm, okmOffset, copyLen) + okmOffset += copyLen + } + + return okm + } + + /** + * Derives encryption key from content hash using HKDF-SHA256. + * Must match hashtree-core: HKDF(content_hash, salt="hashtree-chk", info="encryption-key") + */ + private fun deriveKey(contentHash: ByteArray): ByteArray { + val prk = hkdfExtract(CHK_SALT, contentHash) + return hkdfExpand(prk, CHK_INFO, 32) + } + + /** + * Encrypts data using AES-256-GCM with zero nonce (CHK-safe). + * Returns: [ciphertext][16-byte auth tag] + * + * Zero nonce is safe for CHK because same key = same content (convergent encryption). + */ + private fun chkEncrypt(data: ByteArray, contentHash: ByteArray): ByteArray { + val key = deriveKey(contentHash) + val zeroNonce = ByteArray(NONCE_SIZE) // All zeros + + val cipher = Cipher.getInstance(AES_GCM_ALGORITHM) + val keySpec = SecretKeySpec(key, KEY_ALGORITHM) + val gcmSpec = GCMParameterSpec(TAG_SIZE_BITS, zeroNonce) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec) + + // GCM automatically appends auth tag to ciphertext + return cipher.doFinal(data) + } + + /** + * Decrypts data using AES-256-GCM with zero nonce. + * Expects: [ciphertext][16-byte auth tag] + */ + fun chkDecrypt(data: ByteArray, contentHash: ByteArray): ByteArray { + val key = deriveKey(contentHash) + val zeroNonce = ByteArray(NONCE_SIZE) + + val cipher = Cipher.getInstance(AES_GCM_ALGORITHM) + val keySpec = SecretKeySpec(key, KEY_ALGORITHM) + val gcmSpec = GCMParameterSpec(TAG_SIZE_BITS, zeroNonce) + cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec) + + return cipher.doFinal(data) + } + + /** + * Computes SHA-256 hash of data. + */ + private fun sha256(data: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(data) + + /** + * Converts bytes to lowercase hex string. + */ + private fun ByteArray.toHex(): String = + joinToString(separator = "") { byte -> "%02x".format(byte) } + + /** + * Splits payload into chunks and encrypts each using CHK. + * + * Each chunk is encrypted with a key derived from its content hash via HKDF. + * The root hash is computed by hashing all chunk hashes concatenated. + * + * **CRITICAL**: Uses hashtree-core compatible encryption: + * - HKDF-SHA256 key derivation with salt="hashtree-chk" + * - AES-256-GCM with zero nonce + * + * @param data The data to chunk and encrypt + * @param chunkSize Maximum size of each chunk (default 48KB) + * @return Chunking result with root hash and encrypted chunks + */ + fun chunkPayload( + data: ByteArray, + chunkSize: Int = Transport.MAX_CHUNK_SIZE, + ): ChunkingResult { + val chunks = mutableListOf() + val chunkHashes = mutableListOf() + + var offset = 0 + var index = 0 + while (offset < data.size) { + val end = minOf(offset + chunkSize, data.size) + val chunkData = data.sliceArray(offset until end) + + // Compute hash of plaintext chunk (used for key derivation) + val hash = sha256(chunkData) + chunkHashes.add(hash) + + // Encrypt chunk using HKDF-derived key from hash + val encrypted = chkEncrypt(chunkData, hash) + + chunks.add( + ChunkData( + index = index, + hash = hash, + encrypted = encrypted, + ), + ) + + offset = end + index++ + } + + // Compute root hash from all chunk hashes + val rootHashInput = chunkHashes.fold(ByteArray(0)) { acc, h -> acc + h } + val rootHash = sha256(rootHashInput).toHex() + + return ChunkingResult( + rootHash = rootHash, + totalSize = data.size, + chunks = chunks, + ) + } + + /** + * Converts chunk data to base64 for transport. + */ + fun encodeChunkData(chunk: ChunkData): String = + Base64.encodeToString(chunk.encrypted, Base64.NO_WRAP) + + /** + * Converts chunk hash to hex string. + */ + fun encodeChunkHash(chunk: ChunkData): String = chunk.hash.toHex() +} diff --git a/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Nip17CrashSender.kt b/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Nip17CrashSender.kt index 93c5bec..2ccbb2a 100644 --- a/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Nip17CrashSender.kt +++ b/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Nip17CrashSender.kt @@ -1,41 +1,291 @@ package com.bugstr.nostr.crypto +import android.util.Base64 +import org.json.JSONArray +import org.json.JSONObject + /** * Glue helper for hosts: builds gift wraps, signs seal/gift-wrap, and hands signed events off to a publisher. * This keeps networking and signing pluggable so existing stacks (e.g., Primal) can wire in their own components. + * + * For large payloads (>50KB), uses CHK chunking: + * - Chunks are published as public events (kind 10422) + * - Manifest with root hash is gift-wrapped (kind 10421) + * - Only the recipient can decrypt chunks using the root hash + * + * Uses round-robin relay distribution to maximize throughput while respecting + * per-relay rate limits (8 posts/min for strfry+noteguard). */ class Nip17CrashSender( private val payloadBuilder: Nip17PayloadBuilder, private val signer: NostrEventSigner, private val publisher: NostrEventPublisher, ) { - suspend fun send(request: Nip17SendRequest): Result { - val wraps = payloadBuilder.buildGiftWraps(request.toNip17Request()) + companion object { + private const val TAG = "Nip17CrashSender" + } + + /** Track last post time per relay for rate limiting. */ + private val lastPostTime = mutableMapOf() + + /** + * Send a crash report via NIP-17 gift wrap. + * + * For large reports (>50KB), chunks are distributed across relays using + * round-robin to maximize throughput while respecting rate limits. + * + * @param request The send request containing payload and relay list. + * @param onProgress Optional callback for upload progress (fires asynchronously). + */ + suspend fun send( + request: Nip17SendRequest, + onProgress: BugstrProgressCallback? = null, + ): Result { + // Build DirectPayload to measure actual wrapped size + val directPayload = buildDirectPayload(request.plaintext) + val payloadSize = directPayload.toString().toByteArray(Charsets.UTF_8).size + + return if (payloadSize <= Transport.DIRECT_SIZE_THRESHOLD) { + sendDirect(request, directPayload) + } else { + sendChunked(request, onProgress) + } + } + + /** Build the DirectPayload JSON wrapper for crash data. */ + private fun buildDirectPayload(plaintext: String): JSONObject { + val crashJson = try { + JSONObject(plaintext) + } catch (e: org.json.JSONException) { + android.util.Log.w(TAG, "Plaintext is not valid JSON, wrapping as raw string", e) + JSONObject().apply { put("raw", plaintext) } + } + + return JSONObject().apply { + put("v", 1) + put("crash", crashJson) + } + } + + private suspend fun sendDirect(request: Nip17SendRequest, directPayload: JSONObject): Result { + // Preserve caller's messageKind (don't override to Chat) + val directRequest = request.copy( + plaintext = directPayload.toString(), + ) + + val wraps = payloadBuilder.buildGiftWraps(directRequest.toNip17Request()) if (wraps.isEmpty()) return Result.success(Unit) - val signedWraps = - wraps.mapNotNull { wrap -> - val signedSeal = - signer.sign( - event = wrap.seal, - privateKeyHex = wrap.sealSignerPrivateKeyHex, - ).getOrElse { return Result.failure(it) } - - val signedGift = - signer.sign( - event = wrap.giftWrap, - privateKeyHex = wrap.giftWrapPrivateKeyHex, - ).getOrElse { return Result.failure(it) } - - SignedGiftWrap( - rumor = wrap.rumor, - seal = signedSeal, - giftWrap = signedGift, - ) - } + val signedWraps = wraps.mapNotNull { wrap -> + val signedSeal = signer.sign( + event = wrap.seal, + privateKeyHex = wrap.sealSignerPrivateKeyHex, + ).getOrElse { return Result.failure(it) } + + val signedGift = signer.sign( + event = wrap.giftWrap, + privateKeyHex = wrap.giftWrapPrivateKeyHex, + ).getOrElse { return Result.failure(it) } + + SignedGiftWrap( + rumor = wrap.rumor, + seal = signedSeal, + giftWrap = signedGift, + ) + } return publisher.publishGiftWraps(signedWraps) } + + /** Wait for relay rate limit if needed. */ + private suspend fun waitForRateLimit(relayUrl: String) { + val rateLimit = Transport.getRelayRateLimit(relayUrl) + val lastTime = lastPostTime[relayUrl] ?: 0L + val now = System.currentTimeMillis() + val elapsed = now - lastTime + + if (elapsed < rateLimit) { + val waitMs = rateLimit - elapsed + kotlinx.coroutines.delay(waitMs) + } + } + + /** Record post time for rate limiting. */ + private fun recordPostTime(relayUrl: String) { + lastPostTime[relayUrl] = System.currentTimeMillis() + } + + /** + * Publish chunk with verification and retry on failure. + * @return The relay URL where the chunk was successfully published, or null if all failed. + */ + private suspend fun publishChunkWithVerify( + chunk: SignedNostrEvent, + relays: List, + startIndex: Int, + ): String? { + val numRelays = relays.size + + // Try each relay starting from startIndex (round-robin) + for (attempt in 0 until numRelays) { + val relayUrl = relays[(startIndex + attempt) % numRelays] + + // Publish with rate limiting + waitForRateLimit(relayUrl) + val publishResult = publisher.publishChunkToRelay(chunk, relayUrl) + recordPostTime(relayUrl) + + if (publishResult.isFailure) { + continue // Try next relay + } + + // Brief delay before verification to allow relay to process + kotlinx.coroutines.delay(500) + + // Verify the chunk exists + if (publisher.verifyChunkExists(chunk.id, relayUrl)) { + return relayUrl + } + // Verification failed, try next relay + } + + return null // All relays failed + } + + private suspend fun sendChunked( + request: Nip17SendRequest, + onProgress: BugstrProgressCallback?, + ): Result { + val payloadBytes = request.plaintext.toByteArray(Charsets.UTF_8) + val chunkingResult = Chunking.chunkPayload(payloadBytes) + val totalChunks = chunkingResult.chunks.size + val relays = request.relays.ifEmpty { + listOf("wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net") + } + + // Report initial progress + val estimatedSeconds = Transport.estimateUploadSeconds(totalChunks, relays.size) + onProgress?.invoke(BugstrProgress.preparing(totalChunks, estimatedSeconds)) + + // Build and publish chunk events with round-robin distribution and verification + val chunkIds = mutableListOf() + val chunkRelays = mutableMapOf>() + + for ((index, chunk) in chunkingResult.chunks.withIndex()) { + val chunkPayload = ChunkPayload( + index = chunk.index, + hash = Chunking.encodeChunkHash(chunk), + data = Chunking.encodeChunkData(chunk), + ) + + val chunkEvent = buildChunkEvent(chunkPayload, request.senderPrivateKeyHex) + val signedChunk = signer.sign( + event = chunkEvent, + privateKeyHex = request.senderPrivateKeyHex, + ).getOrElse { return Result.failure(it) } + + chunkIds.add(signedChunk.id) + + // Publish with verification and retry (starts at round-robin relay) + val successRelay = publishChunkWithVerify(signedChunk, relays, index % relays.size) + if (successRelay != null) { + chunkRelays[signedChunk.id] = listOf(successRelay) + } else { + // All relays failed for this chunk - abort upload to avoid partial/broken manifest + android.util.Log.e(TAG, "Failed to publish chunk $index/$totalChunks (id: ${signedChunk.id})") + return Result.failure(Exception("Failed to publish chunk $index to any relay after retries")) + } + + // Report progress + val remainingChunks = totalChunks - index - 1 + val remainingSeconds = Transport.estimateUploadSeconds(remainingChunks, relays.size) + onProgress?.invoke(BugstrProgress.uploading(index + 1, totalChunks, remainingSeconds)) + } + + // Report finalizing + onProgress?.invoke(BugstrProgress.finalizing(totalChunks)) + + // Build manifest with relay hints + val manifest = ManifestPayload( + rootHash = chunkingResult.rootHash, + totalSize = chunkingResult.totalSize, + chunkCount = totalChunks, + chunkIds = chunkIds, + chunkRelays = chunkRelays, + ) + + val chunkRelaysJson = JSONObject().apply { + chunkRelays.forEach { (id, urls) -> + put(id, JSONArray(urls)) + } + } + + val manifestJson = JSONObject().apply { + put("v", manifest.v) + put("root_hash", manifest.rootHash) + put("total_size", manifest.totalSize) + put("chunk_count", manifest.chunkCount) + put("chunk_ids", JSONArray(manifest.chunkIds)) + if (chunkRelays.isNotEmpty()) { + put("chunk_relays", chunkRelaysJson) + } + } + + // Build gift wrap for manifest - preserve all request fields except plaintext + val manifestRequest = request.copy( + plaintext = manifestJson.toString(), + ).toNip17Request() + + val wraps = payloadBuilder.buildGiftWraps(manifestRequest) + if (wraps.isEmpty()) return Result.success(Unit) + + val signedWraps = wraps.mapNotNull { wrap -> + val signedSeal = signer.sign( + event = wrap.seal, + privateKeyHex = wrap.sealSignerPrivateKeyHex, + ).getOrElse { return Result.failure(it) } + + val signedGift = signer.sign( + event = wrap.giftWrap, + privateKeyHex = wrap.giftWrapPrivateKeyHex, + ).getOrElse { return Result.failure(it) } + + SignedGiftWrap( + rumor = wrap.rumor, + seal = signedSeal, + giftWrap = signedGift, + ) + } + + val result = publisher.publishGiftWraps(signedWraps) + + // Report completion + if (result.isSuccess) { + onProgress?.invoke(BugstrProgress.completed(totalChunks)) + } + + return result + } + + private fun buildChunkEvent(chunk: ChunkPayload, privateKeyHex: String): UnsignedNostrEvent { + val content = JSONObject().apply { + put("v", chunk.v) + put("index", chunk.index) + put("hash", chunk.hash) + put("data", chunk.data) + }.toString() + + // Derive pubKey from the actual signing key (not a random key) + val pubKeyHex = QuartzPubKeyDeriver().derivePubKeyHex(privateKeyHex) + + return UnsignedNostrEvent( + pubKey = pubKeyHex, + createdAt = TimestampRandomizer().randomize(java.time.Instant.now().epochSecond), + kind = Transport.KIND_CHUNK, + tags = emptyList(), + content = content, + ) + } } data class Nip17SendRequest( @@ -43,6 +293,7 @@ data class Nip17SendRequest( val senderPrivateKeyHex: String, val recipients: List, val plaintext: String, + val relays: List = emptyList(), val expirationSeconds: Long? = null, val replyToEventId: String? = null, val replyRelayHint: String? = null, @@ -83,6 +334,38 @@ fun interface NostrEventSigner { fun sign(event: UnsignedNostrEvent, privateKeyHex: String): Result } -fun interface NostrEventPublisher { +interface NostrEventPublisher { suspend fun publishGiftWraps(wraps: List): Result + + /** + * Publish a chunk event to a specific relay. + * Used for round-robin distribution to maximize throughput while respecting rate limits. + * + * @param chunk The signed chunk event to publish. + * @param relayUrl The relay URL to publish to. + */ + suspend fun publishChunkToRelay(chunk: SignedNostrEvent, relayUrl: String): Result + + /** + * Verify a chunk event exists on a relay. + * Used for publish verification before moving to the next chunk. + * + * @param eventId The event ID to check. + * @param relayUrl The relay URL to query. + * @return True if the event exists on the relay. + */ + suspend fun verifyChunkExists(eventId: String, relayUrl: String): Boolean { + // Default implementation: assume success (for backwards compatibility) + return true + } + + /** + * Publish a chunk event to all relays for redundancy. + * @deprecated Use publishChunkToRelay for round-robin distribution. + */ + @Deprecated("Use publishChunkToRelay for round-robin distribution") + suspend fun publishChunk(chunk: SignedNostrEvent): Result { + // Default: just publish as a standalone event + return Result.success(Unit) + } } diff --git a/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Nip17PayloadBuilder.kt b/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Nip17PayloadBuilder.kt index 56ed895..54789b6 100644 --- a/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Nip17PayloadBuilder.kt +++ b/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Nip17PayloadBuilder.kt @@ -29,17 +29,17 @@ class Nip17PayloadBuilder( require(request.senderPrivateKeyHex.isNotBlank()) { "Sender private key is required." } require(request.senderPubKey.isNotBlank()) { "Sender pubkey is required." } - val rumor = buildRumor(request) - val createdAt = timestampRandomizer.randomize(Instant.now().epochSecond) + // NIP-59: rumor uses actual timestamp, only seal/gift-wrap are randomized + val now = Instant.now().epochSecond + val rumor = buildRumor(request).copy(createdAt = now) return request.recipients.map { recipient -> giftWrapper.wrap( - rumor = rumor.copy(createdAt = createdAt), + rumor = rumor, senderPubKey = request.senderPubKey, senderPrivateKeyHex = request.senderPrivateKeyHex, recipient = recipient, expirationSeconds = request.expirationSeconds, - createdAt = createdAt, ) } } @@ -157,29 +157,27 @@ class Nip59GiftWrapper( senderPrivateKeyHex: String, recipient: Nip17Recipient, expirationSeconds: Long?, - createdAt: Long, ): Nip17GiftWrap { require(rumor.pubKey == senderPubKey) { "Seal pubkey must match sender." } - val sealCreatedAt = timestampRandomizer.randomize(createdAt) - val giftCreatedAt = timestampRandomizer.randomize(createdAt) + // NIP-59: only seal and gift-wrap timestamps are randomized, rumor keeps actual time + val now = Instant.now().epochSecond + val sealCreatedAt = timestampRandomizer.randomize(now) + val giftCreatedAt = timestampRandomizer.randomize(now) val sealedContent = nip44Encryptor.encrypt( senderPrivateKeyHex = senderPrivateKeyHex, receiverPubKeyHex = recipient.pubKeyHex, - plaintext = rumor.copy(createdAt = sealCreatedAt).toJson(), + plaintext = rumor.toJson(), // rumor uses its actual timestamp ) - val sealTags = buildList { - expirationSeconds?.let { add(listOf("expiration", it.toString())) } - } - + // NIP-59: seal tags MUST be empty val seal = UnsignedNostrEvent( pubKey = senderPubKey, createdAt = sealCreatedAt, kind = KIND_SEAL, - tags = sealTags, + tags = emptyList(), content = sealedContent, ) diff --git a/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Transport.kt b/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Transport.kt new file mode 100644 index 0000000..e058e78 --- /dev/null +++ b/android/bugstr-nostr-crypto/src/main/kotlin/com/bugstr/nostr/crypto/Transport.kt @@ -0,0 +1,168 @@ +package com.bugstr.nostr.crypto + +/** + * Transport layer constants and types for crash report delivery. + * + * Supports both direct delivery (<=50KB) and hashtree-based chunked + * delivery (>50KB) for large crash reports. + * + * Event kinds: + * - 10420: Direct crash report (small payloads, gift-wrapped) + * - 10421: Manifest with root hash and chunk metadata (gift-wrapped) + * - 10422: CHK-encrypted chunk data (public, content-addressed) + */ +object Transport { + /** Event kind for direct crash report delivery (<=50KB). */ + const val KIND_DIRECT = 10420 + + /** Event kind for hashtree manifest (>50KB crash reports). */ + const val KIND_MANIFEST = 10421 + + /** Event kind for CHK-encrypted chunk data. */ + const val KIND_CHUNK = 10422 + + /** Size threshold for switching from direct to chunked transport (50KB). */ + const val DIRECT_SIZE_THRESHOLD = 50 * 1024 + + /** Maximum chunk size (48KB, accounts for base64 + relay overhead). */ + const val MAX_CHUNK_SIZE = 48 * 1024 + + /** Determines transport kind based on payload size. */ + fun getTransportKind(size: Int): TransportKind = + if (size <= DIRECT_SIZE_THRESHOLD) TransportKind.Direct else TransportKind.Chunked + + // ------------------------------------------------------------------------- + // Relay Rate Limiting + // ------------------------------------------------------------------------- + + /** + * Known relay rate limits in milliseconds between posts. + * Based on strfry + noteguard default: 8 posts/minute = 7500ms between posts. + */ + val RELAY_RATE_LIMITS = mapOf( + "wss://relay.damus.io" to 7500L, + "wss://nos.lol" to 7500L, + "wss://relay.primal.net" to 7500L, + ) + + /** Default rate limit for unknown relays (conservative: 8 posts/min). */ + const val DEFAULT_RELAY_RATE_LIMIT = 7500L + + /** Get rate limit for a relay URL. */ + fun getRelayRateLimit(relayUrl: String): Long = + RELAY_RATE_LIMITS[relayUrl] ?: DEFAULT_RELAY_RATE_LIMIT + + /** Estimate upload time in seconds for given chunks and relays. */ + fun estimateUploadSeconds(totalChunks: Int, numRelays: Int): Int { + // With round-robin, effective rate is numRelays * (1 post / 7.5s) + val msPerChunk = DEFAULT_RELAY_RATE_LIMIT / numRelays + return ((totalChunks * msPerChunk) / 1000).toInt().coerceAtLeast(1) + } +} + +enum class TransportKind { + Direct, + Chunked, +} + +// ------------------------------------------------------------------------- +// Progress Reporting (Apple HIG Compliant) +// ------------------------------------------------------------------------- + +/** Phase of crash report upload. */ +enum class BugstrProgressPhase { + Preparing, + Uploading, + Finalizing, +} + +/** + * Progress state for crash report upload. + * Designed for HIG-compliant determinate progress indicators. + */ +data class BugstrProgress( + /** Current phase of upload. */ + val phase: BugstrProgressPhase, + /** Current chunk being uploaded (1-indexed for display). */ + val currentChunk: Int, + /** Total number of chunks. */ + val totalChunks: Int, + /** Progress as fraction 0.0 to 1.0 (for ProgressBar). */ + val fractionCompleted: Float, + /** Estimated seconds remaining. */ + val estimatedSecondsRemaining: Int, + /** Human-readable status for accessibility/display. */ + val localizedDescription: String, +) { + companion object { + fun preparing(totalChunks: Int, estimatedSeconds: Int) = BugstrProgress( + phase = BugstrProgressPhase.Preparing, + currentChunk = 0, + totalChunks = totalChunks, + fractionCompleted = 0f, + estimatedSecondsRemaining = estimatedSeconds, + localizedDescription = "Preparing crash report...", + ) + + fun uploading(current: Int, total: Int, estimatedSeconds: Int) = BugstrProgress( + phase = BugstrProgressPhase.Uploading, + currentChunk = current, + totalChunks = total, + fractionCompleted = (current.toFloat() / total) * 0.95f, + estimatedSecondsRemaining = estimatedSeconds, + localizedDescription = "Uploading chunk $current of $total", + ) + + fun finalizing(totalChunks: Int) = BugstrProgress( + phase = BugstrProgressPhase.Finalizing, + currentChunk = totalChunks, + totalChunks = totalChunks, + fractionCompleted = 0.95f, + estimatedSecondsRemaining = 2, + localizedDescription = "Finalizing...", + ) + + fun completed(totalChunks: Int) = BugstrProgress( + phase = BugstrProgressPhase.Finalizing, + currentChunk = totalChunks, + totalChunks = totalChunks, + fractionCompleted = 1f, + estimatedSecondsRemaining = 0, + localizedDescription = "Complete", + ) + } +} + +/** Callback type for progress updates. */ +typealias BugstrProgressCallback = (BugstrProgress) -> Unit + +// ------------------------------------------------------------------------- +// Payload Types +// ------------------------------------------------------------------------- + +/** Direct crash report payload (kind 10420). */ +data class DirectPayload( + val v: Int = 1, + val crash: Map, +) + +/** + * Hashtree manifest payload (kind 10421). + * @param chunkRelays Optional relay hints for each chunk (for optimized fetching). + */ +data class ManifestPayload( + val v: Int = 1, + val rootHash: String, + val totalSize: Int, + val chunkCount: Int, + val chunkIds: List, + val chunkRelays: Map>? = null, +) + +/** Chunk payload (kind 10422). */ +data class ChunkPayload( + val v: Int = 1, + val index: Int, + val hash: String, + val data: String, +) diff --git a/dart/lib/src/bugstr_client.dart b/dart/lib/src/bugstr_client.dart index 86f814e..fd72107 100644 --- a/dart/lib/src/bugstr_client.dart +++ b/dart/lib/src/bugstr_client.dart @@ -1,9 +1,15 @@ /// Main Bugstr client for crash reporting. +/// +/// For large crash reports (>50KB), uses CHK chunking: +/// - Chunks are published as public events (kind 10422) +/// - Manifest with root hash is gift-wrapped (kind 10421) +/// - Only the recipient can decrypt chunks using the root hash library; import 'dart:async'; import 'dart:convert'; import 'dart:math'; +import 'dart:typed_data'; import 'dart:ui'; import 'package:crypto/crypto.dart'; @@ -13,6 +19,8 @@ import 'package:ndk/ndk.dart'; import 'config.dart'; import 'payload.dart'; import 'compression.dart'; +import 'transport.dart'; +import 'chunking.dart'; /// Main entry point for Bugstr crash reporting. class Bugstr { @@ -22,6 +30,10 @@ class Bugstr { static bool _initialized = false; static FlutterExceptionHandler? _originalOnError; static ErrorCallback? _originalOnPlatformError; + static BugstrProgressCallback? _onProgress; + + /// Track last post time per relay for rate limiting. + static final Map _lastPostTime = {}; Bugstr._(); @@ -30,12 +42,22 @@ class Bugstr { /// This installs global error handlers for Flutter and Dart errors. /// Call this early in your app's main() function. /// + /// Crash reports are sent asynchronously in the background - they never + /// block the main thread or prevent user interaction after confirmation. + /// + /// For large reports (>50KB), use [onProgress] to show upload progress. + /// The callback fires asynchronously and can be used to update UI state: + /// /// ```dart /// void main() { /// Bugstr.init( /// developerPubkey: 'npub1...', /// environment: 'production', /// release: '1.0.0', + /// onProgress: (progress) { + /// // Update UI state (non-blocking, async callback) + /// uploadNotifier.value = progress; + /// }, /// ); /// runApp(MyApp()); /// } @@ -49,6 +71,7 @@ class Bugstr { int maxStackCharacters = 200000, CrashPayload? Function(CrashPayload payload)? beforeSend, Future Function(String message, String? stackPreview)? confirmSend, + BugstrProgressCallback? onProgress, }) { if (_initialized) return; @@ -65,6 +88,8 @@ class Bugstr { confirmSend: confirmSend, ); + _onProgress = onProgress; + // Decode npub to hex _developerPubkeyHex = _decodePubkey(developerPubkey); if (_developerPubkeyHex == null || _developerPubkeyHex!.isEmpty) { @@ -169,92 +194,289 @@ class Bugstr { unawaited(_sendToNostr(finalPayload)); } - /// Send payload via NIP-17 gift wrap. + /// Build a NIP-17 gift-wrapped event for a rumor. + static Nip01Event _buildGiftWrap(int rumorKind, String content) { + // NIP-59: rumor uses actual timestamp, only seal/gift-wrap are randomized + final rumorCreatedAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final rumorTags = [ + ['p', _developerPubkeyHex!] + ]; + + final serialized = jsonEncode([ + 0, + _senderKeys!.publicKey, + rumorCreatedAt, + rumorKind, + rumorTags, + content, + ]); + final rumorId = sha256.convert(utf8.encode(serialized)).toString(); + + final rumor = { + 'id': rumorId, + 'pubkey': _senderKeys!.publicKey, + 'created_at': rumorCreatedAt, + 'kind': rumorKind, + 'tags': rumorTags, + 'content': content, + 'sig': '', + }; + + final rumorJson = jsonEncode(rumor); + + final sealContent = Nip44.encrypt( + _senderKeys!.privateKey, + _developerPubkeyHex!, + rumorJson, + ); + + final sealEvent = Nip01Event( + pubKey: _senderKeys!.publicKey, + kind: 13, + tags: [], + content: sealContent, + createdAt: _randomPastTimestamp(), + ); + sealEvent.sign(_senderKeys!.privateKey); + + final wrapperKeys = KeyPair.generate(); + final giftContent = Nip44.encrypt( + wrapperKeys.privateKey, + _developerPubkeyHex!, + sealEvent.toJsonString(), + ); + + final giftWrap = Nip01Event( + pubKey: wrapperKeys.publicKey, + kind: 1059, + tags: [ + ['p', _developerPubkeyHex!] + ], + content: giftContent, + createdAt: _randomPastTimestamp(), + ); + giftWrap.sign(wrapperKeys.privateKey); + + return giftWrap; + } + + /// Build a public chunk event (kind 10422). + static Nip01Event _buildChunkEvent(ChunkData chunk) { + final chunkKeys = KeyPair.generate(); + final chunkPayloadData = ChunkPayload( + index: chunk.index, + hash: encodeChunkHash(chunk), + data: encodeChunkData(chunk), + ); + + final event = Nip01Event( + pubKey: chunkKeys.publicKey, + kind: kindChunk, + tags: [], + content: jsonEncode(chunkPayloadData.toJson()), + createdAt: _randomPastTimestamp(), + ); + event.sign(chunkKeys.privateKey); + return event; + } + + /// Publish event to first successful relay. + static Future _publishToRelays(Nip01Event event) async { + for (final relayUrl in _config!.effectiveRelays) { + try { + await _publishToRelay(relayUrl, event); + return; + } catch (e) { + debugPrint('Bugstr: Failed to publish to $relayUrl: $e'); + } + } + } + + /// Publish event to all relays (for chunk redundancy). + static Future _publishToAllRelays(Nip01Event event) async { + final futures = _config!.effectiveRelays.map((url) async { + try { + await _publishToRelay(url, event); + } catch (e) { + debugPrint('Bugstr: Failed to publish chunk to $url: $e'); + } + }); + await Future.wait(futures); + } + + /// Wait for relay rate limit if needed. + static Future _waitForRateLimit(String relayUrl) async { + final rateLimit = getRelayRateLimit(relayUrl); + final lastTime = _lastPostTime[relayUrl] ?? 0; + final now = DateTime.now().millisecondsSinceEpoch; + final elapsed = now - lastTime; + + if (elapsed < rateLimit) { + final waitMs = rateLimit - elapsed; + debugPrint('Bugstr: rate limit wait ${waitMs}ms for $relayUrl'); + await Future.delayed(Duration(milliseconds: waitMs)); + } + } + + /// Record post time for rate limiting. + static void _recordPostTime(String relayUrl) { + _lastPostTime[relayUrl] = DateTime.now().millisecondsSinceEpoch; + } + + /// Publish chunk to a single relay with rate limiting. + static Future _publishChunkToRelay( + String relayUrl, Nip01Event event) async { + await _waitForRateLimit(relayUrl); + await _publishToRelay(relayUrl, event); + _recordPostTime(relayUrl); + } + + /// Verify a chunk event exists on a relay. + static Future _verifyChunkExists(String relayUrl, String eventId) async { + try { + final ndk = Ndk.defaultConfig(); + await ndk.relays.connectRelay(relayUrl); + + // Query for the specific event by ID + final filter = Filter( + ids: [eventId], + kinds: [kindChunk], + limit: 1, + ); + + final events = await ndk.requests + .query(filters: [filter], relayUrls: [relayUrl]) + .timeout(const Duration(seconds: 5)); + + await ndk.relays.disconnectRelay(relayUrl); + + return events.isNotEmpty; + } catch (e) { + debugPrint('Bugstr: verify chunk failed on $relayUrl: $e'); + return false; + } + } + + /// Publish chunk with verification and retry on failure. + /// Returns the relay URL where the chunk was successfully published, or null if all failed. + static Future _publishChunkWithVerify( + Nip01Event event, List relays, int startIndex) async { + final numRelays = relays.length; + + // Try each relay starting from startIndex (round-robin) + for (var attempt = 0; attempt < numRelays; attempt++) { + final relayUrl = relays[(startIndex + attempt) % numRelays]; + + try { + // Publish with rate limiting + await _publishChunkToRelay(relayUrl, event); + + // Brief delay before verification to allow relay to process + await Future.delayed(const Duration(milliseconds: 500)); + + // Verify the chunk exists + if (await _verifyChunkExists(relayUrl, event.id)) { + return relayUrl; + } + debugPrint('Bugstr: chunk verification failed on $relayUrl, trying next'); + } catch (e) { + debugPrint('Bugstr: chunk publish failed on $relayUrl: $e'); + } + // Try next relay + } + + return null; // All relays failed + } + + /// Estimate total upload time based on chunks and relays. + static int _estimateUploadSeconds(int totalChunks, int numRelays) { + // With round-robin, effective rate is numRelays * (1 post / 7.5s) + // Time per chunk = 7.5s / numRelays + final msPerChunk = defaultRelayRateLimit ~/ numRelays; + return (totalChunks * msPerChunk / 1000).ceil(); + } + + /// Send payload via NIP-17 gift wrap, using chunking for large payloads. + /// Uses round-robin relay distribution to maximize throughput while + /// respecting per-relay rate limits (8 posts/min for strfry+noteguard). static Future _sendToNostr(CrashPayload payload) async { if (_senderKeys == null || _developerPubkeyHex == null || _config == null) { return; } try { - // Prepare content (maybe compress) final plaintext = payload.toJsonString(); final content = maybeCompressPayload(plaintext); - - // Build rumor (kind 14, unsigned) - final rumorCreatedAt = _randomPastTimestamp(); - final rumorTags = [ - ['p', _developerPubkeyHex!] - ]; - - // Compute rumor ID per NIP-01 - final serialized = jsonEncode([ - 0, - _senderKeys!.publicKey, - rumorCreatedAt, - 14, - rumorTags, - content, - ]); - final rumorId = sha256.convert(utf8.encode(serialized)).toString(); - - final rumor = { - 'id': rumorId, - 'pubkey': _senderKeys!.publicKey, - 'created_at': rumorCreatedAt, - 'kind': 14, - 'tags': rumorTags, - 'content': content, - 'sig': '', // Empty for rumors per NIP-17 - }; - - final rumorJson = jsonEncode(rumor); - - // Encrypt into seal (kind 13) using NIP-44 - final sealContent = Nip44.encrypt( - _senderKeys!.privateKey, - _developerPubkeyHex!, - rumorJson, - ); - - final sealEvent = Nip01Event( - pubKey: _senderKeys!.publicKey, - kind: 13, - tags: [], - content: sealContent, - createdAt: _randomPastTimestamp(), - ); - sealEvent.sign(_senderKeys!.privateKey); - - // Wrap in gift wrap (kind 1059) with ephemeral key - final wrapperKeys = KeyPair.generate(); - final giftContent = Nip44.encrypt( - wrapperKeys.privateKey, - _developerPubkeyHex!, - sealEvent.toJsonString(), - ); - - final giftWrap = Nip01Event( - pubKey: wrapperKeys.publicKey, - kind: 1059, - tags: [ - ['p', _developerPubkeyHex!] - ], - content: giftContent, - createdAt: _randomPastTimestamp(), - ); - giftWrap.sign(wrapperKeys.privateKey); - - // Publish to relays - for (final relayUrl in _config!.effectiveRelays) { - try { - await _publishToRelay(relayUrl, giftWrap); - return; // Success on first relay - } catch (e) { - debugPrint('Bugstr: Failed to publish to $relayUrl: $e'); + final payloadBytes = Uint8List.fromList(utf8.encode(content)); + final transportKind = getTransportKind(payloadBytes.length); + + if (transportKind == TransportKind.direct) { + // Small payload: direct gift-wrapped delivery (no progress needed) + final directPayload = DirectPayload(crash: payload.toJson()); + final giftWrap = + _buildGiftWrap(kindDirect, jsonEncode(directPayload.toJson())); + await _publishToRelays(giftWrap); + debugPrint('Bugstr: sent direct crash report'); + } else { + // Large payload: chunked delivery with round-robin distribution + debugPrint( + 'Bugstr: payload ${payloadBytes.length} bytes, using chunked transport'); + + final result = chunkPayload(payloadBytes); + final totalChunks = result.chunks.length; + final relays = _config!.effectiveRelays; + debugPrint('Bugstr: split into $totalChunks chunks across ${relays.length} relays'); + + // Report initial progress + final estimatedSeconds = _estimateUploadSeconds(totalChunks, relays.length); + _onProgress?.call(BugstrProgress.preparing(totalChunks, estimatedSeconds)); + + // Build chunk events and track relay assignments with verification + final chunkIds = []; + final chunkRelays = >{}; + + for (var i = 0; i < totalChunks; i++) { + final chunk = result.chunks[i]; + final chunkEvent = _buildChunkEvent(chunk); + chunkIds.add(chunkEvent.id); + + // Publish with verification and retry (starts at round-robin relay) + final successRelay = + await _publishChunkWithVerify(chunkEvent, relays, i % relays.length); + if (successRelay != null) { + chunkRelays[chunkEvent.id] = [successRelay]; + } + // If all relays failed, chunk is lost - receiver will report missing chunk + + // Report progress + final remainingChunks = totalChunks - i - 1; + final remainingSeconds = + _estimateUploadSeconds(remainingChunks, relays.length); + _onProgress + ?.call(BugstrProgress.uploading(i + 1, totalChunks, remainingSeconds)); } + debugPrint('Bugstr: published $totalChunks chunks'); + + // Report finalizing + _onProgress?.call(BugstrProgress.finalizing(totalChunks)); + + // Build and publish manifest with relay hints + final manifest = ManifestPayload( + rootHash: result.rootHash, + totalSize: result.totalSize, + chunkCount: totalChunks, + chunkIds: chunkIds, + chunkRelays: chunkRelays, + ); + final manifestGiftWrap = + _buildGiftWrap(kindManifest, jsonEncode(manifest.toJson())); + await _publishToRelays(manifestGiftWrap); + debugPrint('Bugstr: sent chunked crash report manifest'); + + // Report complete + _onProgress?.call(BugstrProgress.completed(totalChunks)); } } catch (e) { - // Silent failure - don't crash the app debugPrint('Bugstr: Failed to send crash report: $e'); } } diff --git a/dart/lib/src/chunking.dart b/dart/lib/src/chunking.dart new file mode 100644 index 0000000..edb0a67 --- /dev/null +++ b/dart/lib/src/chunking.dart @@ -0,0 +1,199 @@ +/// CHK (Content Hash Key) chunking for large crash reports. +/// +/// Implements hashtree-core compatible encryption where: +/// - Data is split into fixed-size chunks +/// - Each chunk's content hash is derived via HKDF to get the encryption key +/// - AES-256-GCM with zero nonce encrypts each chunk +/// - A root hash is computed from all chunk hashes +/// - Only the manifest (with root hash) needs to be encrypted via NIP-17 +/// - Chunks are public but opaque without the root hash +/// +/// **CRITICAL**: Must match hashtree-core crypto exactly: +/// - Key derivation: HKDF-SHA256(content_hash, salt="hashtree-chk", info="encryption-key") +/// - Cipher: AES-256-GCM with 12-byte zero nonce +/// - Format: [ciphertext][16-byte auth tag] +library; + +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:pointycastle/export.dart'; +import 'transport.dart'; + +/// HKDF salt for CHK derivation (must match hashtree-core) +const _chkSalt = 'hashtree-chk'; + +/// HKDF info for key derivation (must match hashtree-core) +const _chkInfo = 'encryption-key'; + +/// Nonce size for AES-GCM (96 bits) +const _nonceSize = 12; + +/// Auth tag size for AES-GCM (128 bits) +const _tagSize = 16; + +/// Encrypted chunk data before publishing. +class ChunkData { + final int index; + final Uint8List hash; + final Uint8List encrypted; + + const ChunkData({ + required this.index, + required this.hash, + required this.encrypted, + }); +} + +/// Result of chunking a payload. +class ChunkingResult { + final String rootHash; + final int totalSize; + final List chunks; + + const ChunkingResult({ + required this.rootHash, + required this.totalSize, + required this.chunks, + }); +} + +/// Derives encryption key from content hash using HKDF-SHA256. +/// Must match hashtree-core: HKDF(content_hash, salt="hashtree-chk", info="encryption-key") +Uint8List _deriveKey(Uint8List contentHash) { + final hkdf = HKDFKeyDerivator(HMac(SHA256Digest(), 64)); + hkdf.init(HkdfParameters( + contentHash, + 32, // Output key length + Uint8List.fromList(utf8.encode(_chkSalt)), + Uint8List.fromList(utf8.encode(_chkInfo)), + )); + + final key = Uint8List(32); + hkdf.deriveKey(null, 0, key, 0); + return key; +} + +/// Encrypts data using AES-256-GCM with zero nonce (CHK-safe). +/// Returns: [ciphertext][16-byte auth tag] +/// +/// Zero nonce is safe for CHK because same key = same content (convergent encryption). +Uint8List _chkEncrypt(Uint8List data, Uint8List contentHash) { + final key = _deriveKey(contentHash); + final zeroNonce = Uint8List(_nonceSize); // All zeros + + final cipher = GCMBlockCipher(AESEngine()); + cipher.init( + true, // encrypt + AEADParameters( + KeyParameter(key), + _tagSize * 8, // tag length in bits + zeroNonce, + Uint8List(0), // no associated data + ), + ); + + final ciphertext = Uint8List(cipher.getOutputSize(data.length)); + final len = cipher.processBytes(data, 0, data.length, ciphertext, 0); + cipher.doFinal(ciphertext, len); + + return ciphertext; // Includes auth tag appended by GCM +} + +/// Decrypts data using AES-256-GCM with zero nonce. +/// Expects: [ciphertext][16-byte auth tag] +Uint8List chkDecrypt(Uint8List data, Uint8List contentHash) { + final key = _deriveKey(contentHash); + final zeroNonce = Uint8List(_nonceSize); + + final cipher = GCMBlockCipher(AESEngine()); + cipher.init( + false, // decrypt + AEADParameters( + KeyParameter(key), + _tagSize * 8, + zeroNonce, + Uint8List(0), + ), + ); + + // getOutputSize for decrypt returns plaintext length (excludes auth tag) + final plaintext = Uint8List(cipher.getOutputSize(data.length)); + final len = cipher.processBytes(data, 0, data.length, plaintext, 0); + cipher.doFinal(plaintext, len); + + return plaintext; +} + +/// Computes SHA-256 hash of data. +Uint8List _sha256(Uint8List data) { + return Uint8List.fromList(sha256.convert(data).bytes); +} + +/// Converts bytes to lowercase hex string. +String _bytesToHex(Uint8List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +} + +/// Splits payload into chunks and encrypts each using CHK. +/// +/// Each chunk is encrypted with a key derived from its content hash via HKDF. +/// The root hash is computed by hashing all chunk hashes concatenated. +/// +/// **CRITICAL**: Uses hashtree-core compatible encryption: +/// - HKDF-SHA256 key derivation with salt="hashtree-chk" +/// - AES-256-GCM with zero nonce +ChunkingResult chunkPayload(Uint8List data, {int chunkSize = maxChunkSize}) { + final chunks = []; + final chunkHashes = []; + + var offset = 0; + var index = 0; + while (offset < data.length) { + final end = offset + chunkSize > data.length ? data.length : offset + chunkSize; + final chunkData = data.sublist(offset, end); + + // Compute hash of plaintext chunk (used for key derivation) + final hash = _sha256(chunkData); + chunkHashes.add(hash); + + // Encrypt chunk using HKDF-derived key from hash + final encrypted = _chkEncrypt(chunkData, hash); + + chunks.add(ChunkData( + index: index, + hash: hash, + encrypted: encrypted, + )); + + offset = end; + index++; + } + + // Compute root hash from all chunk hashes + final rootHashInput = Uint8List.fromList( + chunkHashes.expand((h) => h).toList(), + ); + final rootHash = _bytesToHex(_sha256(rootHashInput)); + + return ChunkingResult( + rootHash: rootHash, + totalSize: data.length, + chunks: chunks, + ); +} + +/// Converts chunk data to base64 for transport. +String encodeChunkData(ChunkData chunk) { + return base64Encode(chunk.encrypted); +} + +/// Converts chunk hash to hex string. +String encodeChunkHash(ChunkData chunk) { + return _bytesToHex(chunk.hash); +} + +/// Estimates the number of chunks needed for a payload size. +int estimateChunkCount(int payloadSize, {int chunkSize = maxChunkSize}) { + return (payloadSize / chunkSize).ceil(); +} diff --git a/dart/lib/src/transport.dart b/dart/lib/src/transport.dart new file mode 100644 index 0000000..4f2d07f --- /dev/null +++ b/dart/lib/src/transport.dart @@ -0,0 +1,217 @@ +/// Transport layer constants and types for crash report delivery. +/// +/// Supports both direct delivery (<=50KB) and hashtree-based chunked +/// delivery (>50KB) for large crash reports. +library; + +/// Event kind for direct crash report delivery (<=50KB). +const int kindDirect = 10420; + +// --------------------------------------------------------------------------- +// Relay Rate Limiting +// --------------------------------------------------------------------------- + +/// Known relay rate limits in milliseconds between posts. +/// Based on strfry + noteguard default: 8 posts/minute = 7500ms between posts. +const Map relayRateLimits = { + 'wss://relay.damus.io': 7500, + 'wss://nos.lol': 7500, + 'wss://relay.primal.net': 7500, +}; + +/// Default rate limit for unknown relays (conservative: 8 posts/min). +const int defaultRelayRateLimit = 7500; + +/// Get rate limit for a relay URL. +int getRelayRateLimit(String relayUrl) { + return relayRateLimits[relayUrl] ?? defaultRelayRateLimit; +} + +// --------------------------------------------------------------------------- +// Progress Reporting (Apple HIG Compliant) +// --------------------------------------------------------------------------- + +/// Phase of crash report upload. +enum BugstrProgressPhase { + preparing, + uploading, + finalizing, +} + +/// Progress state for crash report upload. +/// Designed for HIG-compliant determinate progress indicators. +class BugstrProgress { + /// Current phase of upload. + final BugstrProgressPhase phase; + + /// Current chunk being uploaded (1-indexed for display). + final int currentChunk; + + /// Total number of chunks. + final int totalChunks; + + /// Progress as fraction 0.0 to 1.0 (for UIProgressView/ProgressView). + final double fractionCompleted; + + /// Estimated seconds remaining. + final int estimatedSecondsRemaining; + + /// Human-readable status for accessibility/display. + final String localizedDescription; + + const BugstrProgress({ + required this.phase, + required this.currentChunk, + required this.totalChunks, + required this.fractionCompleted, + required this.estimatedSecondsRemaining, + required this.localizedDescription, + }); + + /// Create progress for preparing phase. + factory BugstrProgress.preparing(int totalChunks, int estimatedSeconds) { + return BugstrProgress( + phase: BugstrProgressPhase.preparing, + currentChunk: 0, + totalChunks: totalChunks, + fractionCompleted: 0.0, + estimatedSecondsRemaining: estimatedSeconds, + localizedDescription: 'Preparing crash report...', + ); + } + + /// Create progress for uploading phase. + factory BugstrProgress.uploading( + int current, int total, int estimatedSeconds) { + return BugstrProgress( + phase: BugstrProgressPhase.uploading, + currentChunk: current, + totalChunks: total, + fractionCompleted: current / total * 0.95, // Reserve 5% for finalizing + estimatedSecondsRemaining: estimatedSeconds, + localizedDescription: 'Uploading chunk $current of $total', + ); + } + + /// Create progress for finalizing phase. + factory BugstrProgress.finalizing(int totalChunks) { + return BugstrProgress( + phase: BugstrProgressPhase.finalizing, + currentChunk: totalChunks, + totalChunks: totalChunks, + fractionCompleted: 0.95, + estimatedSecondsRemaining: 2, + localizedDescription: 'Finalizing...', + ); + } + + /// Create progress for completion. + factory BugstrProgress.completed(int totalChunks) { + return BugstrProgress( + phase: BugstrProgressPhase.finalizing, + currentChunk: totalChunks, + totalChunks: totalChunks, + fractionCompleted: 1.0, + estimatedSecondsRemaining: 0, + localizedDescription: 'Complete', + ); + } +} + +/// Callback type for progress updates. +typedef BugstrProgressCallback = void Function(BugstrProgress progress); + +// --------------------------------------------------------------------------- +// Event Kinds +// --------------------------------------------------------------------------- + +/// Event kind for hashtree manifest (>50KB crash reports). +const int kindManifest = 10421; + +/// Event kind for CHK-encrypted chunk data. +const int kindChunk = 10422; + +/// Size threshold for switching from direct to chunked transport (50KB). +const int directSizeThreshold = 50 * 1024; + +/// Maximum chunk size (48KB, accounts for base64 + relay overhead). +const int maxChunkSize = 48 * 1024; + +/// Transport kind enumeration. +enum TransportKind { + direct, + chunked, +} + +/// Determines transport kind based on payload size. +TransportKind getTransportKind(int size) { + return size <= directSizeThreshold ? TransportKind.direct : TransportKind.chunked; +} + +/// Direct crash report payload (kind 10420). +class DirectPayload { + final int v; + final Map crash; + + const DirectPayload({this.v = 1, required this.crash}); + + Map toJson() => {'v': v, 'crash': crash}; +} + +/// Hashtree manifest payload (kind 10421). +class ManifestPayload { + final int v; + final String rootHash; + final int totalSize; + final int chunkCount; + final List chunkIds; + + /// Optional relay hints for each chunk (for optimized fetching). + /// Maps chunk ID to list of relay URLs where that chunk was published. + final Map>? chunkRelays; + + const ManifestPayload({ + this.v = 1, + required this.rootHash, + required this.totalSize, + required this.chunkCount, + required this.chunkIds, + this.chunkRelays, + }); + + Map toJson() { + final json = { + 'v': v, + 'root_hash': rootHash, + 'total_size': totalSize, + 'chunk_count': chunkCount, + 'chunk_ids': chunkIds, + }; + if (chunkRelays != null && chunkRelays!.isNotEmpty) { + json['chunk_relays'] = chunkRelays; + } + return json; + } +} + +/// Chunk payload (kind 10422). +class ChunkPayload { + final int v; + final int index; + final String hash; + final String data; + + const ChunkPayload({ + this.v = 1, + required this.index, + required this.hash, + required this.data, + }); + + Map toJson() => { + 'v': v, + 'index': index, + 'hash': hash, + 'data': data, + }; +} diff --git a/dart/pubspec.yaml b/dart/pubspec.yaml index 109062d..ec7157e 100644 --- a/dart/pubspec.yaml +++ b/dart/pubspec.yaml @@ -17,6 +17,8 @@ dependencies: path_provider: ^2.1.0 # Crypto utilities crypto: ^3.0.3 + # AES-GCM encryption and HKDF for CHK chunking (hashtree-core compatible) + pointycastle: ^3.7.4 dev_dependencies: flutter_test: diff --git a/electron/package-lock.json b/electron/package-lock.json index f8f7b70..081bb77 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,15 +1,16 @@ { - "name": "bugstr-ts", + "name": "bugstr-electron", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "bugstr-ts", + "name": "bugstr-electron", "version": "0.1.0", "license": "MIT", "dependencies": { "@scure/base": "^2.0.0", + "electron-store": "^10.0.0", "nostr-tools": "^2.5.2" }, "devDependencies": { @@ -20,6 +21,39 @@ "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^4.0.14" + }, + "peerDependencies": { + "electron": ">=20.0.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1163,6 +1197,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -1170,6 +1216,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1195,6 +1265,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1202,17 +1278,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -1220,6 +1312,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.48.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", @@ -1607,6 +1709,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1647,6 +1788,16 @@ "node": ">=12" } }, + "node_modules/atomically": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.0.tgz", + "integrity": "sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q==", + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1654,6 +1805,14 @@ "dev": true, "license": "MIT" }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "optional": true + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -1664,6 +1823,15 @@ "balanced-match": "^1.0.0" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1690,6 +1858,33 @@ "node": ">=8" } }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1743,6 +1938,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1780,6 +1987,63 @@ "dev": true, "license": "MIT" }, + "node_modules/conf": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/conf/-/conf-14.0.0.tgz", + "integrity": "sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw==", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "atomically": "^2.0.3", + "debounce-fn": "^6.0.0", + "dot-prop": "^9.0.0", + "env-paths": "^3.0.0", + "json-schema-typed": "^8.0.1", + "semver": "^7.7.2", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/conf/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -1812,11 +2076,25 @@ "node": ">= 8" } }, + "node_modules/debounce-fn": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", + "integrity": "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1830,6 +2108,33 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1837,6 +2142,160 @@ "dev": true, "license": "MIT" }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT", + "optional": true + }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron": { + "version": "40.0.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-40.0.0.tgz", + "integrity": "sha512-UyBy5yJ0/wm4gNugCtNPjvddjAknMTuXR2aCHioXicH7aKRKGDBPp4xqTEi/doVcB3R+MN3wfU9o8d/9pwgK2A==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^24.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-store": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-10.1.0.tgz", + "integrity": "sha512-oL8bRy7pVCLpwhmXy05Rh/L6O93+k9t6dqSw0+MckIc3OmCTZm6Mp04Q4f/J0rtu84Ky6ywkR8ivtGOmrq+16w==", + "license": "MIT", + "dependencies": { + "conf": "^14.0.0", + "type-fest": "^4.41.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.10.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", + "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1844,6 +2303,13 @@ "dev": true, "license": "MIT" }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT", + "optional": true + }, "node_modules/esbuild": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", @@ -1891,7 +2357,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -2135,11 +2601,30 @@ "node": ">=12.0.0" } }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -2156,6 +2641,31 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2237,6 +2747,20 @@ "dev": true, "license": "ISC" }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2252,6 +2776,21 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2265,6 +2804,24 @@ "node": ">=10.13.0" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -2278,6 +2835,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2311,6 +2929,38 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -2405,7 +3055,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -2415,6 +3064,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -2422,11 +3077,26 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -2499,6 +3169,15 @@ "dev": true, "license": "MIT" }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2509,6 +3188,40 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -2542,7 +3255,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -2583,6 +3295,18 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nostr-tools": { "version": "2.18.1", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.18.1.tgz", @@ -2634,6 +3358,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -2645,6 +3379,15 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2663,6 +3406,15 @@ "node": ">= 0.8.0" } }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -2735,6 +3487,12 @@ "dev": true, "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2861,6 +3619,25 @@ "node": ">= 0.8.0" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2871,6 +3648,18 @@ "node": ">=6" } }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2885,6 +3674,21 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2895,6 +3699,36 @@ "node": ">=4" } }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/rollup": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", @@ -2941,7 +3775,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2950,6 +3783,42 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3000,6 +3869,13 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3027,6 +3903,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3050,6 +3941,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3233,6 +4136,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3255,13 +4170,33 @@ "dev": true, "license": "MIT" }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3920,6 +4855,12 @@ "node": ">=12" } }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3963,6 +4904,22 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/electron/src/chunking.ts b/electron/src/chunking.ts new file mode 100644 index 0000000..36ce8db --- /dev/null +++ b/electron/src/chunking.ts @@ -0,0 +1,193 @@ +/** + * CHK (Content Hash Key) chunking for large crash reports. + * + * Implements hashtree-core compatible encryption where: + * - Data is split into fixed-size chunks + * - Each chunk's content hash is derived via HKDF to get the encryption key + * - AES-256-GCM with zero nonce encrypts each chunk + * - A root hash is computed from all chunk hashes + * - Only the manifest (with root hash) needs to be encrypted via NIP-17 + * - Chunks are public but opaque without the root hash + * + * **CRITICAL**: Must match hashtree-core crypto exactly: + * - Key derivation: HKDF-SHA256(content_hash, salt="hashtree-chk", info="encryption-key") + * - Cipher: AES-256-GCM with 12-byte zero nonce + * - Format: [ciphertext][16-byte auth tag] + */ +import { createHash, createCipheriv, createDecipheriv, hkdfSync } from "crypto"; +import { MAX_CHUNK_SIZE } from "./transport.js"; + +/** HKDF salt for CHK derivation (must match hashtree-core) */ +const CHK_SALT = Buffer.from("hashtree-chk"); + +/** HKDF info for key derivation (must match hashtree-core) */ +const CHK_INFO = Buffer.from("encryption-key"); + +/** Nonce size for AES-GCM (96 bits) */ +const NONCE_SIZE = 12; + +/** Auth tag size for AES-GCM (128 bits) */ +const TAG_SIZE = 16; + +export type ChunkData = { + index: number; + hash: string; + encrypted: Buffer; +}; + +export type ChunkingResult = { + rootHash: string; + totalSize: number; + chunks: ChunkData[]; +}; + +/** + * Computes SHA-256 hash of data. + */ +function sha256(data: Buffer): Buffer { + return createHash("sha256").update(data).digest(); +} + +/** + * Derives encryption key from content hash using HKDF-SHA256. + * Must match hashtree-core: HKDF(content_hash, salt="hashtree-chk", info="encryption-key") + */ +function deriveKey(contentHash: Buffer): Buffer { + return Buffer.from(hkdfSync("sha256", contentHash, CHK_SALT, CHK_INFO, 32)); +} + +/** + * Encrypts data using AES-256-GCM with zero nonce (CHK-safe). + * Returns: [ciphertext][16-byte auth tag] + * + * Zero nonce is safe for CHK because same key = same content (convergent encryption). + */ +function chkEncrypt(data: Buffer, contentHash: Buffer): Buffer { + const key = deriveKey(contentHash); + const zeroNonce = Buffer.alloc(NONCE_SIZE); // All zeros + + const cipher = createCipheriv("aes-256-gcm", key, zeroNonce); + const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]); + const authTag = cipher.getAuthTag(); + + // GCM format: [ciphertext][auth tag] + return Buffer.concat([ciphertext, authTag]); +} + +/** + * Decrypts data using AES-256-GCM with zero nonce. + * Expects: [ciphertext][16-byte auth tag] + */ +function chkDecrypt(data: Buffer, contentHash: Buffer): Buffer { + const key = deriveKey(contentHash); + const zeroNonce = Buffer.alloc(NONCE_SIZE); + + const ciphertext = data.subarray(0, data.length - TAG_SIZE); + const authTag = data.subarray(data.length - TAG_SIZE); + + const decipher = createDecipheriv("aes-256-gcm", key, zeroNonce); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +/** + * Splits payload into chunks and encrypts each using CHK. + * + * Each chunk is encrypted with a key derived from its content hash via HKDF. + * The root hash is computed by hashing all chunk hashes concatenated. + * + * **CRITICAL**: Uses hashtree-core compatible encryption: + * - HKDF-SHA256 key derivation with salt="hashtree-chk" + * - AES-256-GCM with zero nonce + * + * @param payload The data to chunk and encrypt + * @param chunkSize Maximum size of each chunk (default 48KB) + * @returns Chunking result with root hash and encrypted chunks + */ +export function chunkPayload( + payload: Buffer, + chunkSize = MAX_CHUNK_SIZE +): ChunkingResult { + const chunks: ChunkData[] = []; + const chunkHashes: Buffer[] = []; + + let offset = 0; + let index = 0; + + while (offset < payload.length) { + const end = Math.min(offset + chunkSize, payload.length); + const chunkData = payload.subarray(offset, end); + + // Compute hash of plaintext chunk (used for key derivation) + const hash = sha256(chunkData); + chunkHashes.push(hash); + + // Encrypt chunk using HKDF-derived key from hash + const encrypted = chkEncrypt(chunkData, hash); + + chunks.push({ + index, + hash: hash.toString("hex"), + encrypted, + }); + + offset = end; + index++; + } + + // Compute root hash from all chunk hashes + const rootHashInput = Buffer.concat(chunkHashes); + const rootHash = sha256(rootHashInput).toString("hex"); + + return { + rootHash, + totalSize: payload.length, + chunks, + }; +} + +/** + * Reassembles payload from chunks using the root hash for verification. + * + * @param rootHash Expected root hash (from manifest) + * @param chunks Encrypted chunks with their hashes + * @returns Reassembled original payload + * @throws Error if root hash doesn't match or decryption fails + */ +export function reassemblePayload( + rootHash: string, + chunks: ChunkData[] +): Buffer { + // Sort by index to ensure correct order + const sorted = [...chunks].sort((a, b) => a.index - b.index); + + // Verify root hash + const chunkHashes = sorted.map((c) => Buffer.from(c.hash, "hex")); + const computedRoot = sha256(Buffer.concat(chunkHashes)).toString("hex"); + + if (computedRoot !== rootHash) { + throw new Error( + `Root hash mismatch: expected ${rootHash}, got ${computedRoot}` + ); + } + + // Decrypt and concatenate chunks + const decrypted: Buffer[] = []; + for (const chunk of sorted) { + const contentHash = Buffer.from(chunk.hash, "hex"); + const plaintext = chkDecrypt(chunk.encrypted, contentHash); + decrypted.push(plaintext); + } + + return Buffer.concat(decrypted); +} + +/** + * Estimates the number of chunks needed for a payload size. + */ +export function estimateChunkCount( + payloadSize: number, + chunkSize = MAX_CHUNK_SIZE +): number { + return Math.ceil(payloadSize / chunkSize); +} diff --git a/electron/src/index.ts b/electron/src/index.ts index f7a72cf..52697ad 100644 --- a/electron/src/index.ts +++ b/electron/src/index.ts @@ -1,2 +1,4 @@ export * from "./sdk.js"; export * from "./compression.js"; +export * from "./transport.js"; +export * from "./chunking.js"; diff --git a/electron/src/sdk.ts b/electron/src/sdk.ts index 0370160..cb8738d 100644 --- a/electron/src/sdk.ts +++ b/electron/src/sdk.ts @@ -3,8 +3,25 @@ * * Captures crashes, caches them locally, and sends via NIP-17 gift-wrapped * encrypted DMs on next app launch with user consent. + * + * For large crash reports (>50KB), uses CHK chunking: + * - Chunks are published as public events (kind 10422) + * - Manifest with root hash is gift-wrapped (kind 10421) + * - Only the recipient can decrypt chunks using the root hash */ import { nip19, nip44, finalizeEvent, generateSecretKey, getPublicKey, getEventHash, Relay } from "nostr-tools"; +import { maybeCompressPayload } from "./compression.js"; +import { + KIND_DIRECT, + KIND_MANIFEST, + KIND_CHUNK, + DIRECT_SIZE_THRESHOLD, + getTransportKind, + createDirectPayload, + type ManifestPayload, + type ChunkPayload, +} from "./transport.js"; +import { chunkPayload, type ChunkData } from "./chunking.js"; import Store from "electron-store"; export type BugstrConfig = { @@ -141,34 +158,34 @@ export function clearPendingReports(): void { store.set("pendingReports", []); } -async function sendToNostr(payload: BugstrPayload): Promise { - if (!developerPubkeyHex || !senderPrivkey) { - throw new Error("Bugstr Nostr keys not configured"); - } - - const relays = config.relays?.length ? config.relays : DEFAULT_RELAYS; - const plaintext = JSON.stringify(payload); - - // Build rumor (kind 14, unsigned) +/** + * Build a NIP-17 gift-wrapped event for a rumor. + */ +function buildGiftWrap( + rumorKind: number, + content: string, + senderPrivkey: Uint8Array, + recipientPubkey: string +): ReturnType { + // NIP-59: rumor uses actual timestamp, only seal/gift-wrap are randomized const rumorEvent = { - kind: 14, - created_at: randomPastTimestamp(), - tags: [["p", developerPubkeyHex]], - content: plaintext, + kind: rumorKind, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", recipientPubkey]], + content, pubkey: getPublicKey(senderPrivkey), }; - // Compute rumor ID per NIP-01 const rumorId = getEventHash(rumorEvent); - const unsignedKind14 = { + const unsignedRumor = { ...rumorEvent, id: rumorId, sig: "", // Empty signature for rumors per NIP-17 }; // Seal (kind 13) - const conversationKey = nip44.getConversationKey(senderPrivkey, developerPubkeyHex); - const sealContent = nip44.encrypt(JSON.stringify(unsignedKind14), conversationKey); + const conversationKey = nip44.getConversationKey(senderPrivkey, recipientPubkey); + const sealContent = nip44.encrypt(JSON.stringify(unsignedRumor), conversationKey); const seal = finalizeEvent( { kind: 13, @@ -181,26 +198,124 @@ async function sendToNostr(payload: BugstrPayload): Promise { // Gift wrap (kind 1059) const wrapperPrivBytes = generateSecretKey(); - const wrapKey = nip44.getConversationKey(wrapperPrivBytes, developerPubkeyHex); + const wrapKey = nip44.getConversationKey(wrapperPrivBytes, recipientPubkey); const giftWrapContent = nip44.encrypt(JSON.stringify(seal), wrapKey); - const giftWrap = finalizeEvent( + return finalizeEvent( { kind: 1059, created_at: randomPastTimestamp(), - tags: [["p", developerPubkeyHex]], + tags: [["p", recipientPubkey]], content: giftWrapContent, }, wrapperPrivBytes ); +} + +/** + * Build a public chunk event (kind 10422). + */ +function buildChunkEvent(chunk: ChunkData): ReturnType { + const chunkPrivkey = generateSecretKey(); + const chunkPayload: ChunkPayload = { + v: 1, + index: chunk.index, + hash: chunk.hash, + data: chunk.encrypted.toString("base64"), + }; + return finalizeEvent( + { + kind: KIND_CHUNK, + created_at: randomPastTimestamp(), + tags: [], + content: JSON.stringify(chunkPayload), + }, + chunkPrivkey + ); +} - // Publish to relays +async function sendToNostr(payload: BugstrPayload): Promise { + if (!developerPubkeyHex || !senderPrivkey) { + throw new Error("Bugstr Nostr keys not configured"); + } + + const relays = config.relays?.length ? config.relays : DEFAULT_RELAYS; + + // Compress and check size + const rawJson = JSON.stringify(payload); + const compressed = maybeCompressPayload(rawJson); + const payloadBytes = Buffer.from(compressed, "utf-8"); + const transportKind = getTransportKind(payloadBytes.length); + + if (transportKind === "direct") { + // Small payload: direct gift-wrapped delivery + const directPayload = createDirectPayload(payload as Record); + const giftWrap = buildGiftWrap( + KIND_DIRECT, + JSON.stringify(directPayload), + senderPrivkey, + developerPubkeyHex + ); + + await publishToRelays(relays, giftWrap); + console.info("Bugstr: sent direct crash report"); + } else { + // Large payload: chunked delivery + console.info(`Bugstr: payload ${payloadBytes.length} bytes, using chunked transport`); + + const { rootHash, totalSize, chunks } = chunkPayload(payloadBytes); + console.info(`Bugstr: split into ${chunks.length} chunks`); + + // Build chunk events + const chunkEvents = chunks.map(buildChunkEvent); + + // Publish chunks to all relays with delay to avoid rate limiting + const chunkIds: string[] = []; + const CHUNK_PUBLISH_DELAY_MS = 100; // Delay between chunks to avoid relay rate limits + for (let i = 0; i < chunkEvents.length; i++) { + const chunkEvent = chunkEvents[i]; + chunkIds.push(chunkEvent.id); + await publishToAllRelays(relays, chunkEvent); + // Add delay between chunks (not after last chunk) + if (i < chunkEvents.length - 1) { + await new Promise((resolve) => setTimeout(resolve, CHUNK_PUBLISH_DELAY_MS)); + } + } + console.info(`Bugstr: published ${chunks.length} chunks`); + + // Build and publish manifest + const manifest: ManifestPayload = { + v: 1, + root_hash: rootHash, + total_size: totalSize, + chunk_count: chunks.length, + chunk_ids: chunkIds, + }; + + const manifestGiftWrap = buildGiftWrap( + KIND_MANIFEST, + JSON.stringify(manifest), + senderPrivkey, + developerPubkeyHex + ); + + await publishToRelays(relays, manifestGiftWrap); + console.info("Bugstr: sent chunked crash report manifest"); + } +} + +/** + * Publish an event to the first successful relay. + */ +async function publishToRelays( + relays: string[], + event: ReturnType +): Promise { let lastError: Error | undefined; for (const relayUrl of relays) { try { const relay = await Relay.connect(relayUrl); - await relay.publish(giftWrap); + await relay.publish(event); relay.close(); - console.info(`Bugstr: published to ${relayUrl}`); return; } catch (err) { lastError = err as Error; @@ -209,6 +324,27 @@ async function sendToNostr(payload: BugstrPayload): Promise { throw lastError || new Error("Unable to publish Bugstr event"); } +/** + * Publish an event to all relays (for chunk redundancy). + */ +async function publishToAllRelays( + relays: string[], + event: ReturnType +): Promise { + const results = await Promise.allSettled( + relays.map(async (relayUrl) => { + const relay = await Relay.connect(relayUrl); + await relay.publish(event); + relay.close(); + }) + ); + + const successful = results.filter((r) => r.status === "fulfilled").length; + if (successful === 0) { + throw new Error("Unable to publish chunk to any relay"); + } +} + async function showElectronDialog(summary: BugstrSummary): Promise { // Dynamic import to avoid issues when bundling const { dialog } = await import("electron"); diff --git a/electron/src/transport.ts b/electron/src/transport.ts new file mode 100644 index 0000000..cdcfd15 --- /dev/null +++ b/electron/src/transport.ts @@ -0,0 +1,54 @@ +/** + * Transport layer constants and types for crash report delivery. + * + * Supports both direct delivery (<=50KB) and hashtree-based chunked + * delivery (>50KB) for large crash reports. + */ + +/** Event kind for direct crash report delivery (<=50KB). */ +export const KIND_DIRECT = 10420; + +/** Event kind for hashtree manifest (>50KB crash reports). */ +export const KIND_MANIFEST = 10421; + +/** Event kind for CHK-encrypted chunk data. */ +export const KIND_CHUNK = 10422; + +/** Size threshold for switching from direct to chunked transport (50KB). */ +export const DIRECT_SIZE_THRESHOLD = 50 * 1024; + +/** Maximum chunk size (48KB, accounts for base64 + relay overhead). */ +export const MAX_CHUNK_SIZE = 48 * 1024; + +/** Direct crash report payload (kind 10420). */ +export type DirectPayload = { + v: number; + crash: Record; +}; + +/** Hashtree manifest payload (kind 10421). */ +export type ManifestPayload = { + v: number; + root_hash: string; + total_size: number; + chunk_count: number; + chunk_ids: string[]; +}; + +/** Chunk payload (kind 10422). */ +export type ChunkPayload = { + v: number; + index: number; + hash: string; + data: string; +}; + +/** Determines transport kind based on payload size. */ +export function getTransportKind(size: number): "direct" | "chunked" { + return size <= DIRECT_SIZE_THRESHOLD ? "direct" : "chunked"; +} + +/** Creates a direct payload wrapper. */ +export function createDirectPayload(crash: Record): DirectPayload { + return { v: 1, crash }; +} diff --git a/go/bugstr.go b/go/bugstr.go index 5f32504..19a6fff 100644 --- a/go/bugstr.go +++ b/go/bugstr.go @@ -3,6 +3,11 @@ // Bugstr delivers crash reports via Nostr gift-wrapped encrypted direct messages // with user consent. Reports auto-expire after 30 days. // +// For large crash reports (>50KB), uses CHK chunking: +// - Chunks are published as public events (kind 10422) +// - Manifest with root hash is gift-wrapped (kind 10421) +// - Only the recipient can decrypt chunks using the root hash +// // Basic usage: // // bugstr.Init(bugstr.Config{ @@ -17,12 +22,15 @@ import ( "bytes" "compress/gzip" "context" + "crypto/aes" + "crypto/cipher" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" - "math/rand" + "io" + mathrand "math/rand" "regexp" "runtime" "strings" @@ -32,8 +40,99 @@ import ( "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip19" "github.com/nbd-wtf/go-nostr/nip44" + "golang.org/x/crypto/hkdf" +) + +// Transport layer constants +const ( + // KindDirect is the event kind for direct crash report delivery (<=50KB). + KindDirect = 10420 + // KindManifest is the event kind for hashtree manifest (>50KB crash reports). + KindManifest = 10421 + // KindChunk is the event kind for CHK-encrypted chunk data. + KindChunk = 10422 + // DirectSizeThreshold is the size threshold for switching to chunked transport (50KB). + DirectSizeThreshold = 50 * 1024 + // MaxChunkSize is the maximum chunk size (48KB). + MaxChunkSize = 48 * 1024 + // DefaultRelayRateLimit is the rate limit for strfry+noteguard relays (8 posts/min = 7500ms). + DefaultRelayRateLimit = 7500 * time.Millisecond +) + +// RelayRateLimits contains known relay rate limits. +var RelayRateLimits = map[string]time.Duration{ + "wss://relay.damus.io": 7500 * time.Millisecond, + "wss://nos.lol": 7500 * time.Millisecond, + "wss://relay.primal.net": 7500 * time.Millisecond, +} + +// GetRelayRateLimit returns the rate limit for a relay URL. +func GetRelayRateLimit(relayURL string) time.Duration { + if limit, ok := RelayRateLimits[relayURL]; ok { + return limit + } + return DefaultRelayRateLimit +} + +// EstimateUploadSeconds estimates upload time for given chunks and relays. +func EstimateUploadSeconds(totalChunks, numRelays int) int { + msPerChunk := int(DefaultRelayRateLimit.Milliseconds()) / numRelays + return (totalChunks * msPerChunk) / 1000 +} + +// ProgressPhase represents the current phase of upload. +type ProgressPhase string + +const ( + ProgressPhasePreparing ProgressPhase = "preparing" + ProgressPhaseUploading ProgressPhase = "uploading" + ProgressPhaseFinalizing ProgressPhase = "finalizing" ) +// Progress represents upload progress for HIG-compliant UI. +type Progress struct { + Phase ProgressPhase + CurrentChunk int + TotalChunks int + FractionCompleted float64 + EstimatedSecondsRemaining int + LocalizedDescription string +} + +// ProgressCallback is called with upload progress. +type ProgressCallback func(Progress) + +// DirectPayload wraps crash data for direct delivery (kind 10420). +type DirectPayload struct { + V int `json:"v"` + Crash interface{} `json:"crash"` +} + +// ManifestPayload contains metadata for chunked crash reports (kind 10421). +type ManifestPayload struct { + V int `json:"v"` + RootHash string `json:"root_hash"` + TotalSize int `json:"total_size"` + ChunkCount int `json:"chunk_count"` + ChunkIDs []string `json:"chunk_ids"` + ChunkRelays map[string][]string `json:"chunk_relays,omitempty"` +} + +// ChunkPayload contains encrypted chunk data (kind 10422). +type ChunkPayload struct { + V int `json:"v"` + Index int `json:"index"` + Hash string `json:"hash"` + Data string `json:"data"` +} + +// ChunkData holds chunked data before publishing. +type ChunkData struct { + Index int + Hash []byte + Encrypted []byte +} + // Config holds the Bugstr configuration. type Config struct { // DeveloperPubkey is the recipient's public key (npub or hex). @@ -60,6 +159,10 @@ type Config struct { // ConfirmSend prompts the user before sending. Return true to send. // If nil, reports are sent automatically (suitable for servers). ConfirmSend func(summary Summary) bool + + // OnProgress is called with upload progress for large crash reports. + // Fires asynchronously - does not block the main goroutine. + OnProgress ProgressCallback } // Payload is the crash report data sent to the developer. @@ -85,11 +188,13 @@ type CompressedEnvelope struct { } var ( - config Config - senderPrivkey string + config Config + senderPrivkey string developerPubkeyHex string - initialized bool - initMu sync.Mutex + initialized bool + initMu sync.Mutex + lastPostTime = make(map[string]time.Time) + lastPostTimeMu sync.Mutex defaultRelays = []string{"wss://relay.damus.io", "wss://relay.primal.net", "wss://nos.lol"} @@ -261,10 +366,104 @@ func truncateStack(stack string, lines int) string { func randomPastTimestamp() int64 { now := time.Now().Unix() maxOffset := int64(60 * 60 * 24 * 2) // up to 2 days - offset := rand.Int63n(maxOffset) + offset := mathrand.Int63n(maxOffset) return now - offset } +// HKDF salt for CHK derivation (must match hashtree-core) +var chkSalt = []byte("hashtree-chk") + +// HKDF info for key derivation (must match hashtree-core) +var chkInfo = []byte("encryption-key") + +// Nonce size for AES-GCM (96 bits) +const nonceSize = 12 + +// deriveKey derives encryption key from content hash using HKDF-SHA256. +// Must match hashtree-core: HKDF(content_hash, salt="hashtree-chk", info="encryption-key") +func deriveKey(contentHash []byte) ([]byte, error) { + hkdfReader := hkdf.New(sha256.New, contentHash, chkSalt, chkInfo) + key := make([]byte, 32) + if _, err := io.ReadFull(hkdfReader, key); err != nil { + return nil, err + } + return key, nil +} + +// chkEncrypt encrypts data using AES-256-GCM with zero nonce (CHK-safe). +// Returns: [ciphertext][16-byte auth tag] +// +// Zero nonce is safe for CHK because same key = same content (convergent encryption). +// +// **CRITICAL**: Must match hashtree-core crypto exactly: +// - Key derivation: HKDF-SHA256(content_hash, salt="hashtree-chk", info="encryption-key") +// - Cipher: AES-256-GCM with 12-byte zero nonce +// - Format: [ciphertext][16-byte auth tag] +func chkEncrypt(data, contentHash []byte) ([]byte, error) { + key, err := deriveKey(contentHash) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + // Zero nonce is safe for CHK (same key = same content) + zeroNonce := make([]byte, nonceSize) + + // GCM Seal appends auth tag to ciphertext + return gcm.Seal(nil, zeroNonce, data, nil), nil +} + +// chunkPayloadData splits data into chunks and encrypts each using CHK. +func chunkPayloadData(data []byte) (rootHash string, chunks []ChunkData, err error) { + var chunkHashes [][]byte + + offset := 0 + index := 0 + for offset < len(data) { + end := offset + MaxChunkSize + if end > len(data) { + end = len(data) + } + chunkData := data[offset:end] + + // Compute hash of plaintext chunk (becomes encryption key) + hash := sha256.Sum256(chunkData) + chunkHashes = append(chunkHashes, hash[:]) + + // Encrypt chunk using its hash as key + encrypted, err := chkEncrypt(chunkData, hash[:]) + if err != nil { + return "", nil, err + } + + chunks = append(chunks, ChunkData{ + Index: index, + Hash: hash[:], + Encrypted: encrypted, + }) + + offset = end + index++ + } + + // Compute root hash from all chunk hashes + var rootHashInput []byte + for _, h := range chunkHashes { + rootHashInput = append(rootHashInput, h...) + } + rootHashBytes := sha256.Sum256(rootHashInput) + return hex.EncodeToString(rootHashBytes[:]), chunks, nil +} + func maybeCompress(plaintext string) string { if len(plaintext) < 1024 { return plaintext @@ -285,32 +484,21 @@ func maybeCompress(plaintext string) string { return string(result) } -func sendToNostr(ctx context.Context, payload *Payload) error { - relays := config.Relays - if len(relays) == 0 { - relays = defaultRelays - } - - plaintext, err := json.Marshal(payload) - if err != nil { - return err - } - - content := maybeCompress(string(plaintext)) +// buildGiftWrap creates a NIP-17 gift-wrapped event for a rumor. +func buildGiftWrap(rumorKind int, content string) (nostr.Event, error) { senderPubkey, _ := nostr.GetPublicKey(senderPrivkey) - // Build unsigned kind 14 rumor + // NIP-59: rumor uses actual timestamp, only seal/gift-wrap are randomized rumor := map[string]interface{}{ - "id": "", // Computed later + "id": "", "pubkey": senderPubkey, - "created_at": randomPastTimestamp(), - "kind": 14, + "created_at": time.Now().Unix(), + "kind": rumorKind, "tags": [][]string{{"p", developerPubkeyHex}}, "content": content, "sig": "", } - // Compute rumor ID per NIP-01: sha256 of [0, pubkey, created_at, kind, tags, content] serialized, _ := json.Marshal([]interface{}{ 0, rumor["pubkey"], @@ -320,18 +508,16 @@ func sendToNostr(ctx context.Context, payload *Payload) error { rumor["content"], }) hash := sha256.Sum256(serialized) - rumorID := hex.EncodeToString(hash[:]) - rumor["id"] = rumorID + rumor["id"] = hex.EncodeToString(hash[:]) - // Encrypt rumor into seal rumorBytes, _ := json.Marshal(rumor) conversationKey, err := nip44.GenerateConversationKey(senderPrivkey, developerPubkeyHex) if err != nil { - return err + return nostr.Event{}, err } sealContent, err := nip44.Encrypt(string(rumorBytes), conversationKey) if err != nil { - return err + return nostr.Event{}, err } seal := nostr.Event{ @@ -342,17 +528,16 @@ func sendToNostr(ctx context.Context, payload *Payload) error { } seal.Sign(senderPrivkey) - // Wrap seal in gift wrap with random key wrapperPrivkey := nostr.GeneratePrivateKey() wrapKey, err := nip44.GenerateConversationKey(wrapperPrivkey, developerPubkeyHex) if err != nil { - return err + return nostr.Event{}, err } sealJSON, _ := json.Marshal(seal) giftContent, err := nip44.Encrypt(string(sealJSON), wrapKey) if err != nil { - return err + return nostr.Event{}, err } giftWrap := nostr.Event{ @@ -363,7 +548,32 @@ func sendToNostr(ctx context.Context, payload *Payload) error { } giftWrap.Sign(wrapperPrivkey) - // Publish to relays + return giftWrap, nil +} + +// buildChunkEvent creates a public chunk event (kind 10422). +func buildChunkEvent(chunk ChunkData) nostr.Event { + chunkPrivkey := nostr.GeneratePrivateKey() + chunkPayload := ChunkPayload{ + V: 1, + Index: chunk.Index, + Hash: hex.EncodeToString(chunk.Hash), + Data: base64.StdEncoding.EncodeToString(chunk.Encrypted), + } + content, _ := json.Marshal(chunkPayload) + + event := nostr.Event{ + Kind: KindChunk, + CreatedAt: nostr.Timestamp(randomPastTimestamp()), + Tags: nostr.Tags{}, + Content: string(content), + } + event.Sign(chunkPrivkey) + return event +} + +// publishToRelays publishes an event to the first successful relay. +func publishToRelays(ctx context.Context, relays []string, event nostr.Event) error { var lastErr error for _, relayURL := range relays { relay, err := nostr.RelayConnect(ctx, relayURL) @@ -371,13 +581,264 @@ func sendToNostr(ctx context.Context, payload *Payload) error { lastErr = err continue } - err = relay.Publish(ctx, giftWrap) + err = relay.Publish(ctx, event) relay.Close() if err == nil { return nil } lastErr = err } - return lastErr } + +// publishToAllRelays publishes an event to all relays for redundancy. +func publishToAllRelays(ctx context.Context, relays []string, event nostr.Event) error { + var wg sync.WaitGroup + successCount := 0 + var mu sync.Mutex + + for _, relayURL := range relays { + wg.Add(1) + go func(url string) { + defer wg.Done() + relay, err := nostr.RelayConnect(ctx, url) + if err != nil { + return + } + err = relay.Publish(ctx, event) + relay.Close() + if err == nil { + mu.Lock() + successCount++ + mu.Unlock() + } + }(relayURL) + } + + wg.Wait() + if successCount == 0 { + return fmt.Errorf("failed to publish chunk to any relay") + } + return nil +} + +// waitForRateLimit waits until enough time has passed since the last post to this relay. +func waitForRateLimit(relayURL string) { + // Compute sleep duration while holding lock to avoid race condition + lastPostTimeMu.Lock() + lastTime, exists := lastPostTime[relayURL] + var sleepDuration time.Duration + if exists { + rateLimit := GetRelayRateLimit(relayURL) + deadline := lastTime.Add(rateLimit) + sleepDuration = time.Until(deadline) + } + lastPostTimeMu.Unlock() + + // Sleep outside of lock + if sleepDuration > 0 { + time.Sleep(sleepDuration) + } +} + +// recordPostTime records the time of a post to a relay. +func recordPostTime(relayURL string) { + lastPostTimeMu.Lock() + lastPostTime[relayURL] = time.Now() + lastPostTimeMu.Unlock() +} + +// publishChunkToRelay publishes a chunk to a single relay with rate limiting. +func publishChunkToRelay(ctx context.Context, relayURL string, event nostr.Event) error { + waitForRateLimit(relayURL) + + relay, err := nostr.RelayConnect(ctx, relayURL) + if err != nil { + return err + } + defer relay.Close() + + err = relay.Publish(ctx, event) + if err == nil { + recordPostTime(relayURL) + } + return err +} + +// verifyChunkExists queries a relay to verify a chunk event exists. +func verifyChunkExists(ctx context.Context, relayURL string, eventID string) bool { + verifyCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + relay, err := nostr.RelayConnect(verifyCtx, relayURL) + if err != nil { + return false + } + defer relay.Close() + + filter := nostr.Filter{ + IDs: []string{eventID}, + Kinds: []int{KindChunk}, + Limit: 1, + } + + events, err := relay.QuerySync(verifyCtx, filter) + if err != nil { + return false + } + + return len(events) > 0 +} + +// publishChunkWithVerify publishes a chunk and verifies it was stored. +// Returns the relay URL where the chunk was successfully published, or error. +func publishChunkWithVerify(ctx context.Context, relays []string, startIndex int, event nostr.Event) (string, error) { + numRelays := len(relays) + + // Try each relay starting from startIndex (round-robin) + for attempt := 0; attempt < numRelays; attempt++ { + relayURL := relays[(startIndex+attempt)%numRelays] + + // Publish with rate limiting + if err := publishChunkToRelay(ctx, relayURL, event); err != nil { + continue // Try next relay + } + + // Brief delay before verification to allow relay to process + time.Sleep(500 * time.Millisecond) + + // Verify the chunk exists on the relay + if verifyChunkExists(ctx, relayURL, event.ID) { + return relayURL, nil + } + // Verification failed, try next relay + } + + return "", fmt.Errorf("failed to publish and verify chunk on any relay") +} + +func sendToNostr(ctx context.Context, payload *Payload) error { + relays := config.Relays + if len(relays) == 0 { + relays = defaultRelays + } + + plaintext, err := json.Marshal(payload) + if err != nil { + return err + } + + content := maybeCompress(string(plaintext)) + payloadSize := len(content) + + if payloadSize <= DirectSizeThreshold { + // Small payload: direct gift-wrapped delivery + directPayload := DirectPayload{V: 1, Crash: payload} + directContent, _ := json.Marshal(directPayload) + + giftWrap, err := buildGiftWrap(KindDirect, string(directContent)) + if err != nil { + return err + } + + return publishToRelays(ctx, relays, giftWrap) + } + + // Large payload: chunked delivery with round-robin distribution + rootHash, chunks, err := chunkPayloadData([]byte(content)) + if err != nil { + return err + } + + totalChunks := len(chunks) + numRelays := len(relays) + + // Report initial progress + if config.OnProgress != nil { + estimatedSeconds := EstimateUploadSeconds(totalChunks, numRelays) + config.OnProgress(Progress{ + Phase: ProgressPhasePreparing, + CurrentChunk: 0, + TotalChunks: totalChunks, + FractionCompleted: 0, + EstimatedSecondsRemaining: estimatedSeconds, + LocalizedDescription: "Preparing crash report...", + }) + } + + // Build and publish chunk events with round-robin distribution and verification + chunkIDs := make([]string, totalChunks) + chunkRelays := make(map[string][]string) + + for i, chunk := range chunks { + chunkEvent := buildChunkEvent(chunk) + chunkIDs[i] = chunkEvent.ID + + // Publish with verification and retry (starts at round-robin relay) + successRelay, err := publishChunkWithVerify(ctx, relays, i%numRelays, chunkEvent) + if err != nil { + // All relays failed for this chunk - abort to avoid partial/broken manifest + log.Printf("Failed to publish chunk %d/%d (id: %s): %v", i, totalChunks, chunkEvent.ID, err) + return fmt.Errorf("failed to publish chunk %d to any relay after retries: %w", i, err) + } + chunkRelays[chunkEvent.ID] = []string{successRelay} + + // Report progress + if config.OnProgress != nil { + remainingChunks := totalChunks - i - 1 + remainingSeconds := EstimateUploadSeconds(remainingChunks, numRelays) + config.OnProgress(Progress{ + Phase: ProgressPhaseUploading, + CurrentChunk: i + 1, + TotalChunks: totalChunks, + FractionCompleted: float64(i+1) / float64(totalChunks) * 0.95, + EstimatedSecondsRemaining: remainingSeconds, + LocalizedDescription: fmt.Sprintf("Uploading chunk %d of %d", i+1, totalChunks), + }) + } + } + + // Report finalizing + if config.OnProgress != nil { + config.OnProgress(Progress{ + Phase: ProgressPhaseFinalizing, + CurrentChunk: totalChunks, + TotalChunks: totalChunks, + FractionCompleted: 0.95, + EstimatedSecondsRemaining: 2, + LocalizedDescription: "Finalizing...", + }) + } + + // Build and publish manifest with relay hints + manifest := ManifestPayload{ + V: 1, + RootHash: rootHash, + TotalSize: len(content), + ChunkCount: totalChunks, + ChunkIDs: chunkIDs, + ChunkRelays: chunkRelays, + } + manifestContent, _ := json.Marshal(manifest) + + manifestGiftWrap, err := buildGiftWrap(KindManifest, string(manifestContent)) + if err != nil { + return err + } + + err = publishToRelays(ctx, relays, manifestGiftWrap) + + // Report complete + if err == nil && config.OnProgress != nil { + config.OnProgress(Progress{ + Phase: ProgressPhaseFinalizing, + CurrentChunk: totalChunks, + TotalChunks: totalChunks, + FractionCompleted: 1.0, + EstimatedSecondsRemaining: 0, + LocalizedDescription: "Complete", + }) + } + + return err +} diff --git a/python/bugstr/__init__.py b/python/bugstr/__init__.py index ee7132a..7dd9c1a 100644 --- a/python/bugstr/__init__.py +++ b/python/bugstr/__init__.py @@ -1,6 +1,11 @@ """ Bugstr - Zero-infrastructure crash reporting via NIP-17 encrypted DMs. +For large crash reports (>50KB), uses CHK chunking: +- Chunks are published as public events (kind 10422) +- Manifest with root hash is gift-wrapped (kind 10421) +- Only the recipient can decrypt chunks using the root hash + Basic usage: import bugstr @@ -25,6 +30,7 @@ import os import random import re +import secrets import sys import threading import time @@ -32,6 +38,48 @@ from dataclasses import dataclass, field from typing import Callable, Optional, Pattern +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.backends import default_backend + +# Transport layer constants +KIND_DIRECT = 10420 +KIND_MANIFEST = 10421 +KIND_CHUNK = 10422 +DIRECT_SIZE_THRESHOLD = 50 * 1024 # 50KB +MAX_CHUNK_SIZE = 48 * 1024 # 48KB + +# Relay rate limits (strfry + noteguard: 8 posts/min = 7.5s between posts) +DEFAULT_RELAY_RATE_LIMIT = 7.5 # seconds +RELAY_RATE_LIMITS = { + "wss://relay.damus.io": 7.5, + "wss://nos.lol": 7.5, + "wss://relay.primal.net": 7.5, +} + + +def get_relay_rate_limit(relay_url: str) -> float: + """Get rate limit for a relay URL in seconds.""" + return RELAY_RATE_LIMITS.get(relay_url, DEFAULT_RELAY_RATE_LIMIT) + + +def estimate_upload_seconds(total_chunks: int, num_relays: int) -> int: + """Estimate upload time for given chunks and relays.""" + sec_per_chunk = DEFAULT_RELAY_RATE_LIMIT / num_relays + return max(1, int(total_chunks * sec_per_chunk)) + + +@dataclass +class Progress: + """Progress state for crash report upload (HIG-compliant).""" + phase: str # 'preparing', 'uploading', 'finalizing' + current_chunk: int + total_chunks: int + fraction_completed: float + estimated_seconds_remaining: int + localized_description: str + # Nostr imports - using nostr-sdk try: from nostr_sdk import Keys, Client, Event, EventBuilder, Kind, Tag, PublicKey, SecretKey @@ -87,6 +135,9 @@ class Config: confirm_send: Optional[Callable[[str, str], bool]] = None """Hook to confirm before sending. Args: (message, stack_preview). Return True to send.""" + on_progress: Optional[Callable[["Progress"], None]] = None + """Progress callback for large crash reports. Fires in background thread.""" + @dataclass class Payload: @@ -116,6 +167,8 @@ def to_dict(self) -> dict: _initialized = False _lock = threading.Lock() _original_excepthook = None +_last_post_time: dict[str, float] = {} +_last_post_time_lock = threading.Lock() def init( @@ -292,6 +345,89 @@ def _maybe_compress(plaintext: str) -> str: return json.dumps(envelope) +# HKDF salt for CHK derivation (must match hashtree-core) +CHK_SALT = b"hashtree-chk" + +# HKDF info for key derivation (must match hashtree-core) +CHK_INFO = b"encryption-key" + +# Nonce size for AES-GCM (96 bits) +NONCE_SIZE = 12 + + +def _derive_key(content_hash: bytes) -> bytes: + """Derive encryption key from content hash using HKDF-SHA256. + + Must match hashtree-core: HKDF(content_hash, salt="hashtree-chk", info="encryption-key") + """ + hkdf = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=CHK_SALT, + info=CHK_INFO, + backend=default_backend(), + ) + return hkdf.derive(content_hash) + + +def _chk_encrypt(data: bytes, content_hash: bytes) -> bytes: + """Encrypt data using AES-256-GCM with zero nonce (CHK-safe). + + Returns: [ciphertext][16-byte auth tag] + + Zero nonce is safe for CHK because same key = same content (convergent encryption). + + **CRITICAL**: Must match hashtree-core crypto exactly: + - Key derivation: HKDF-SHA256(content_hash, salt="hashtree-chk", info="encryption-key") + - Cipher: AES-256-GCM with 12-byte zero nonce + - Format: [ciphertext][16-byte auth tag] + """ + key = _derive_key(content_hash) + zero_nonce = bytes(NONCE_SIZE) # All zeros + + aesgcm = AESGCM(key) + # AESGCM.encrypt returns ciphertext with auth tag appended + return aesgcm.encrypt(zero_nonce, data, None) + + +def _chunk_payload(data: bytes) -> tuple[str, list[dict]]: + """Split data into chunks and encrypt each using CHK. + + Returns: + Tuple of (root_hash, list of chunk dicts with index, hash, encrypted) + """ + chunks = [] + chunk_hashes = [] + + offset = 0 + index = 0 + while offset < len(data): + end = min(offset + MAX_CHUNK_SIZE, len(data)) + chunk_data = data[offset:end] + + # Compute hash of plaintext (becomes encryption key) + chunk_hash = hashlib.sha256(chunk_data).digest() + chunk_hashes.append(chunk_hash) + + # Encrypt chunk using its hash as key + encrypted = _chk_encrypt(chunk_data, chunk_hash) + + chunks.append({ + "index": index, + "hash": chunk_hash.hex(), + "encrypted": encrypted, + }) + + offset = end + index += 1 + + # Compute root hash from all chunk hashes + root_hash_input = b"".join(chunk_hashes) + root_hash = hashlib.sha256(root_hash_input).hexdigest() + + return root_hash, chunks + + def _maybe_send(payload: Payload) -> None: """Apply hooks and send payload.""" if not _config: @@ -315,71 +451,308 @@ def _maybe_send(payload: Payload) -> None: thread.start() -def _send_to_nostr(payload: Payload) -> None: - """Send payload via NIP-17 gift wrap.""" - if not _sender_keys or not _config: - return +def _build_gift_wrap(rumor_kind: int, content: str) -> Event: + """Build a NIP-17 gift-wrapped event for a rumor.""" + # NIP-59: rumor uses actual timestamp, only seal/gift-wrap are randomized + rumor = { + "pubkey": _sender_keys.public_key().to_hex(), + "created_at": int(time.time()), + "kind": rumor_kind, + "tags": [["p", _developer_pubkey_hex]], + "content": content, + "sig": "", + } - try: - plaintext = json.dumps(payload.to_dict()) - content = _maybe_compress(plaintext) - - # Build rumor (kind 14, unsigned) - rumor = { - "pubkey": _sender_keys.public_key().to_hex(), - "created_at": _random_past_timestamp(), - "kind": 14, - "tags": [["p", _developer_pubkey_hex]], - "content": content, - "sig": "", - } - - # Compute rumor ID - serialized = json.dumps([ - 0, - rumor["pubkey"], - rumor["created_at"], - rumor["kind"], - rumor["tags"], - rumor["content"], - ], separators=(",", ":")) - rumor["id"] = hashlib.sha256(serialized.encode()).hexdigest() - - rumor_json = json.dumps(rumor) - - # Encrypt into seal (kind 13) - developer_pk = PublicKey.from_hex(_developer_pubkey_hex) - seal_content = nip44.encrypt(_sender_keys.secret_key(), developer_pk, rumor_json) - - seal = EventBuilder( - Kind(13), - seal_content, - ).custom_created_at(_random_past_timestamp()).to_event(_sender_keys) - - # Wrap in gift wrap (kind 1059) with random key - wrapper_keys = Keys.generate() - seal_json = seal.as_json() - gift_content = nip44.encrypt(wrapper_keys.secret_key(), developer_pk, seal_json) - - gift_wrap = EventBuilder( - Kind(1059), - gift_content, - ).custom_created_at(_random_past_timestamp()).tags([ - Tag.public_key(developer_pk) - ]).to_event(wrapper_keys) - - # Publish to relays - client = Client(wrapper_keys) - for relay_url in _config.relays: + serialized = json.dumps([ + 0, + rumor["pubkey"], + rumor["created_at"], + rumor["kind"], + rumor["tags"], + rumor["content"], + ], separators=(",", ":")) + rumor["id"] = hashlib.sha256(serialized.encode()).hexdigest() + + rumor_json = json.dumps(rumor) + + developer_pk = PublicKey.from_hex(_developer_pubkey_hex) + seal_content = nip44.encrypt(_sender_keys.secret_key(), developer_pk, rumor_json) + + seal = EventBuilder( + Kind(13), + seal_content, + ).custom_created_at(_random_past_timestamp()).to_event(_sender_keys) + + wrapper_keys = Keys.generate() + seal_json = seal.as_json() + gift_content = nip44.encrypt(wrapper_keys.secret_key(), developer_pk, seal_json) + + return EventBuilder( + Kind(1059), + gift_content, + ).custom_created_at(_random_past_timestamp()).tags([ + Tag.public_key(developer_pk) + ]).to_event(wrapper_keys) + + +def _build_chunk_event(chunk: dict) -> Event: + """Build a public chunk event (kind 10422).""" + chunk_keys = Keys.generate() + chunk_payload = { + "v": 1, + "index": chunk["index"], + "hash": chunk["hash"], + "data": base64.b64encode(chunk["encrypted"]).decode(), + } + return EventBuilder( + Kind(KIND_CHUNK), + json.dumps(chunk_payload), + ).custom_created_at(_random_past_timestamp()).to_event(chunk_keys) + + +def _publish_to_relays(event: Event) -> None: + """Publish an event to the first successful relay.""" + client = Client(Keys.generate()) + for relay_url in _config.relays: + try: + client.add_relay(relay_url) + except Exception: + pass + + client.connect() + client.send_event(event) + client.disconnect() + + +def _publish_to_all_relays(event: Event) -> None: + """Publish an event to all relays for redundancy.""" + # Use threads to publish in parallel + threads = [] + for relay_url in _config.relays: + def publish_to_relay(url): try: - client.add_relay(relay_url) + client = Client(Keys.generate()) + client.add_relay(url) + client.connect() + client.send_event(event) + client.disconnect() except Exception: pass + t = threading.Thread(target=publish_to_relay, args=(relay_url,)) + threads.append(t) + t.start() + + for t in threads: + t.join(timeout=10) + + +def _wait_for_rate_limit(relay_url: str) -> None: + """Wait for relay rate limit if needed.""" + rate_limit = get_relay_rate_limit(relay_url) + + # Compute sleep duration inside lock to prevent race condition + with _last_post_time_lock: + last_time = _last_post_time.get(relay_url, 0) + elapsed = time.time() - last_time + sleep_duration = rate_limit - elapsed if elapsed < rate_limit else 0 + + if sleep_duration > 0: + time.sleep(sleep_duration) + + +def _record_post_time(relay_url: str) -> None: + """Record post time for rate limiting.""" + with _last_post_time_lock: + _last_post_time[relay_url] = time.time() + + +def _publish_chunk_to_relay(event: Event, relay_url: str) -> bool: + """Publish a chunk to a single relay with rate limiting.""" + _wait_for_rate_limit(relay_url) + try: + client = Client(Keys.generate()) + client.add_relay(relay_url) + client.connect() + client.send_event(event) + client.disconnect() + _record_post_time(relay_url) + return True + except Exception: + return False + + +def _verify_chunk_exists(event_id: str, relay_url: str) -> bool: + """Verify a chunk event exists on a relay.""" + try: + from datetime import timedelta + from nostr_sdk import Filter, EventSource + client = Client(Keys.generate()) + client.add_relay(relay_url) client.connect() - client.send_event(gift_wrap) + + # Query for the specific event by ID + filter = Filter().id(event_id).kind(Kind(KIND_CHUNK)).limit(1) + events = client.get_events_of([filter], EventSource.relays(timedelta(seconds=5))) client.disconnect() + return len(events) > 0 + except Exception: + return False + + +def _publish_chunk_with_verify(event: Event, relays: list[str], start_index: int) -> tuple[bool, str]: + """Publish a chunk with verification and retry on failure. + + Args: + event: The chunk event to publish + relays: List of relay URLs + start_index: Starting relay index (for round-robin) + + Returns: + Tuple of (success, relay_url) where relay_url is where the chunk was published + """ + num_relays = len(relays) + event_id = event.id().to_hex() + + # Try each relay starting from start_index + for attempt in range(num_relays): + relay_url = relays[(start_index + attempt) % num_relays] + + # Publish with rate limiting + if not _publish_chunk_to_relay(event, relay_url): + continue # Try next relay + + # Brief delay before verification to allow relay to process + time.sleep(0.5) + + # Verify the chunk exists + if _verify_chunk_exists(event_id, relay_url): + return True, relay_url + + # Verification failed, try next relay + + return False, "" + + +def _send_to_nostr(payload: Payload) -> None: + """Send payload via NIP-17 gift wrap, using chunking for large payloads. + + Uses round-robin relay distribution to maximize throughput while + respecting per-relay rate limits (8 posts/min for strfry+noteguard). + """ + if not _sender_keys or not _config: + return + + try: + plaintext = json.dumps(payload.to_dict()) + plaintext_bytes = plaintext.encode() + payload_size = len(plaintext_bytes) + + if payload_size <= DIRECT_SIZE_THRESHOLD: + # Small payload: direct gift-wrapped delivery (no compression needed) + direct_payload = {"v": 1, "crash": payload.to_dict()} + gift_wrap = _build_gift_wrap(KIND_DIRECT, json.dumps(direct_payload)) + _publish_to_relays(gift_wrap) + else: + # Large payload: compress and chunk for delivery + compressed = _maybe_compress(plaintext) + payload_bytes = compressed.encode() + + # Chunked delivery with round-robin distribution + root_hash, chunks = _chunk_payload(payload_bytes) + total_chunks = len(chunks) + relays = _config.relays or DEFAULT_RELAYS + num_relays = len(relays) + + # Report initial progress + if _config.on_progress: + estimated_seconds = estimate_upload_seconds(total_chunks, num_relays) + _config.on_progress(Progress( + phase="preparing", + current_chunk=0, + total_chunks=total_chunks, + fraction_completed=0.0, + estimated_seconds_remaining=estimated_seconds, + localized_description="Preparing crash report...", + )) + + # Build and publish chunk events with round-robin distribution and verification + chunk_ids = [] + chunk_relays = {} + + for i, chunk in enumerate(chunks): + chunk_event = _build_chunk_event(chunk) + chunk_id = chunk_event.id().to_hex() + + # Publish with verification and retry (starts at round-robin relay) + success, success_relay = _publish_chunk_with_verify(chunk_event, relays, i % num_relays) + if not success: + # Abort upload - don't publish partial/broken manifest + import logging + logging.error(f"Bugstr: failed to publish chunk {i}/{total_chunks} (id: {chunk_id})") + if _config.on_progress: + _config.on_progress(Progress( + phase="uploading", + current_chunk=i, + total_chunks=total_chunks, + fraction_completed=i / total_chunks * 0.95, + estimated_seconds_remaining=0, + localized_description=f"Failed to upload chunk {i}", + )) + return # Silent failure - don't crash the app + + chunk_ids.append(chunk_id) + chunk_relays[chunk_id] = [success_relay] + + # Report progress + if _config.on_progress: + remaining_chunks = total_chunks - i - 1 + remaining_seconds = estimate_upload_seconds(remaining_chunks, num_relays) + _config.on_progress(Progress( + phase="uploading", + current_chunk=i + 1, + total_chunks=total_chunks, + fraction_completed=(i + 1) / total_chunks * 0.95, + estimated_seconds_remaining=remaining_seconds, + localized_description=f"Uploading chunk {i + 1} of {total_chunks}", + )) + + # Report finalizing + if _config.on_progress: + _config.on_progress(Progress( + phase="finalizing", + current_chunk=total_chunks, + total_chunks=total_chunks, + fraction_completed=0.95, + estimated_seconds_remaining=2, + localized_description="Finalizing...", + )) + + # Build and publish manifest with relay hints + # total_size is the compressed data size (what gets chunked and reassembled) + manifest = { + "v": 1, + "root_hash": root_hash, + "total_size": len(payload_bytes), + "chunk_count": total_chunks, + "chunk_ids": chunk_ids, + "chunk_relays": chunk_relays, + } + manifest_gift_wrap = _build_gift_wrap(KIND_MANIFEST, json.dumps(manifest)) + _publish_to_relays(manifest_gift_wrap) + + # Report complete + if _config.on_progress: + _config.on_progress(Progress( + phase="finalizing", + current_chunk=total_chunks, + total_chunks=total_chunks, + fraction_completed=1.0, + estimated_seconds_remaining=0, + localized_description="Complete", + )) + except Exception: # Silent failure - don't crash the app pass diff --git a/python/pyproject.toml b/python/pyproject.toml index 4b35028..0aa0559 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ dependencies = [ "nostr-sdk>=0.32.0", "secp256k1>=0.14.0", + "cryptography>=41.0.0", ] [project.optional-dependencies] diff --git a/react-native/package-lock.json b/react-native/package-lock.json index 5a66b70..f9c2bd5 100644 --- a/react-native/package-lock.json +++ b/react-native/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@noble/hashes": "^2.0.1", + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", "nostr-tools": "^2.10.0" }, "devDependencies": { @@ -2407,10 +2408,13 @@ } }, "node_modules/@noble/ciphers": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", - "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, "funding": { "url": "https://paulmillr.com/funding/" } @@ -2440,12 +2444,12 @@ } }, "node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", "engines": { - "node": ">= 20.19.0" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -6670,6 +6674,15 @@ } } }, + "node_modules/nostr-tools/node_modules/@noble/ciphers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/nostr-tools/node_modules/@noble/hashes": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", diff --git a/react-native/package.json b/react-native/package.json index efebd16..646bf1e 100644 --- a/react-native/package.json +++ b/react-native/package.json @@ -41,6 +41,8 @@ "access": "public" }, "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", "nostr-tools": "^2.10.0" }, "devDependencies": { diff --git a/react-native/src/chunking.ts b/react-native/src/chunking.ts new file mode 100644 index 0000000..9d5d62d --- /dev/null +++ b/react-native/src/chunking.ts @@ -0,0 +1,230 @@ +/** + * CHK (Content Hash Key) chunking for large crash reports. + * + * Uses @noble/hashes and @noble/ciphers for cross-platform crypto + * that works in React Native without native modules. + * + * **CRITICAL**: Must match hashtree-core crypto exactly: + * - Key derivation: HKDF-SHA256(content_hash, salt="hashtree-chk", info="encryption-key") + * - Cipher: AES-256-GCM with 12-byte zero nonce + * - Format: [ciphertext][16-byte auth tag] + */ +import { sha256 } from '@noble/hashes/sha256'; +import { hkdf } from '@noble/hashes/hkdf'; +import { gcm } from '@noble/ciphers/aes'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import { MAX_CHUNK_SIZE } from './transport'; + +/** HKDF salt for CHK derivation (must match hashtree-core) */ +const CHK_SALT = new TextEncoder().encode('hashtree-chk'); + +/** HKDF info for key derivation (must match hashtree-core) */ +const CHK_INFO = new TextEncoder().encode('encryption-key'); + +/** Nonce size for AES-GCM (96 bits) */ +const NONCE_SIZE = 12; + +export type ChunkData = { + index: number; + hash: string; + encrypted: Uint8Array; +}; + +export type ChunkingResult = { + rootHash: string; + totalSize: number; + chunks: ChunkData[]; +}; + +/** + * Derives encryption key from content hash using HKDF-SHA256. + * Must match hashtree-core: HKDF(content_hash, salt="hashtree-chk", info="encryption-key") + */ +function deriveKey(contentHash: Uint8Array): Uint8Array { + return hkdf(sha256, contentHash, CHK_SALT, CHK_INFO, 32); +} + +/** + * Encrypts data using AES-256-GCM with zero nonce (CHK-safe). + * Returns: [ciphertext][16-byte auth tag] + * + * Zero nonce is safe for CHK because same key = same content (convergent encryption). + */ +function chkEncrypt(data: Uint8Array, contentHash: Uint8Array): Uint8Array { + const key = deriveKey(contentHash); + const zeroNonce = new Uint8Array(NONCE_SIZE); // All zeros + const cipher = gcm(key, zeroNonce); + return cipher.encrypt(data); // GCM appends auth tag +} + +/** + * Decrypts data using AES-256-GCM with zero nonce. + * Expects: [ciphertext][16-byte auth tag] + */ +function chkDecrypt(data: Uint8Array, contentHash: Uint8Array): Uint8Array { + const key = deriveKey(contentHash); + const zeroNonce = new Uint8Array(NONCE_SIZE); + const cipher = gcm(key, zeroNonce); + return cipher.decrypt(data); +} + +/** + * Converts string to UTF-8 bytes. + */ +function stringToBytes(str: string): Uint8Array { + return new TextEncoder().encode(str); +} + +/** + * Converts UTF-8 bytes to string. + */ +function bytesToString(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +/** + * Base64 encode bytes. + */ +function bytesToBase64(bytes: Uint8Array): string { + // Works in both browser and React Native + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/** + * Base64 decode to bytes. + */ +function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Splits payload into chunks and encrypts each using CHK. + * + * Each chunk is encrypted with a key derived from its content hash via HKDF. + * The root hash is computed by hashing all chunk hashes concatenated. + * + * **CRITICAL**: Uses hashtree-core compatible encryption: + * - HKDF-SHA256 key derivation with salt="hashtree-chk" + * - AES-256-GCM with zero nonce + * + * @param payload The string data to chunk and encrypt + * @param chunkSize Maximum size of each chunk (default 48KB) + * @returns Chunking result with root hash and encrypted chunks + */ +export function chunkPayload(payload: string, chunkSize = MAX_CHUNK_SIZE): ChunkingResult { + const data = stringToBytes(payload); + const chunks: ChunkData[] = []; + const chunkHashes: Uint8Array[] = []; + + let offset = 0; + let index = 0; + + while (offset < data.length) { + const end = Math.min(offset + chunkSize, data.length); + const chunkData = data.slice(offset, end); + + // Compute hash of plaintext chunk (used for key derivation) + const hash = sha256(chunkData); + chunkHashes.push(hash); + + // Encrypt chunk using HKDF-derived key from hash + const encrypted = chkEncrypt(chunkData, hash); + + chunks.push({ + index, + hash: bytesToHex(hash), + encrypted, + }); + + offset = end; + index++; + } + + // Compute root hash from all chunk hashes + const rootHashInput = new Uint8Array(chunkHashes.reduce((acc, h) => acc + h.length, 0)); + let pos = 0; + for (const h of chunkHashes) { + rootHashInput.set(h, pos); + pos += h.length; + } + const rootHash = bytesToHex(sha256(rootHashInput)); + + return { + rootHash, + totalSize: data.length, + chunks, + }; +} + +/** + * Reassembles payload from chunks using the root hash for verification. + * + * @param rootHash Expected root hash (from manifest) + * @param chunks Encrypted chunks with their hashes + * @returns Reassembled original payload string + * @throws Error if root hash doesn't match or decryption fails + */ +export function reassemblePayload( + rootHash: string, + chunks: Array<{ index: number; hash: string; data: string }> +): string { + // Sort by index to ensure correct order + const sorted = [...chunks].sort((a, b) => a.index - b.index); + + // Verify root hash + const chunkHashes = sorted.map((c) => hexToBytes(c.hash)); + const rootHashInput = new Uint8Array(chunkHashes.reduce((acc, h) => acc + h.length, 0)); + let pos = 0; + for (const h of chunkHashes) { + rootHashInput.set(h, pos); + pos += h.length; + } + const computedRoot = bytesToHex(sha256(rootHashInput)); + + if (computedRoot !== rootHash) { + throw new Error(`Root hash mismatch: expected ${rootHash}, got ${computedRoot}`); + } + + // Decrypt and concatenate chunks + const decrypted: Uint8Array[] = []; + for (const chunk of sorted) { + const contentHash = hexToBytes(chunk.hash); + const encrypted = base64ToBytes(chunk.data); + const plaintext = chkDecrypt(encrypted, contentHash); + decrypted.push(plaintext); + } + + // Concatenate all decrypted chunks + const totalLength = decrypted.reduce((acc, d) => acc + d.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const d of decrypted) { + result.set(d, offset); + offset += d.length; + } + + return bytesToString(result); +} + +/** + * Estimates the number of chunks needed for a payload size. + */ +export function estimateChunkCount(payloadSize: number, chunkSize = MAX_CHUNK_SIZE): number { + return Math.ceil(payloadSize / chunkSize); +} + +/** + * Converts chunk data to base64 for transport. + */ +export function encodeChunkData(chunk: ChunkData): string { + return bytesToBase64(chunk.encrypted); +} diff --git a/react-native/src/index.ts b/react-native/src/index.ts index 3fab744..df51d0b 100644 --- a/react-native/src/index.ts +++ b/react-native/src/index.ts @@ -3,6 +3,11 @@ * * Zero-infrastructure crash reporting for React Native via NIP-17 encrypted DMs. * + * For large crash reports (>50KB), uses CHK chunking: + * - Chunks are published as public events (kind 10422) + * - Manifest with root hash is gift-wrapped (kind 10421) + * - Only the recipient can decrypt chunks using the root hash + * * @example * ```tsx * import * as Bugstr from '@bugstr/react-native'; @@ -28,6 +33,28 @@ import React, { Component, ErrorInfo, ReactNode } from 'react'; import { Alert, Platform } from 'react-native'; import { nip19, nip44, finalizeEvent, generateSecretKey, getPublicKey, Relay, getEventHash } from 'nostr-tools'; import type { UnsignedEvent } from 'nostr-tools'; +import { + KIND_DIRECT, + KIND_MANIFEST, + KIND_CHUNK, + DIRECT_SIZE_THRESHOLD, + getTransportKind, + createDirectPayload, + getRelayRateLimit, + estimateUploadSeconds, + progressPreparing, + progressUploading, + progressFinalizing, + progressCompleted, + type ManifestPayload, + type ChunkPayload, + type BugstrProgress, + type BugstrProgressCallback, +} from './transport'; +import { chunkPayload, encodeChunkData, type ChunkData } from './chunking'; + +// Re-export progress types +export type { BugstrProgress, BugstrProgressCallback } from './transport'; // Types export type BugstrConfig = { @@ -40,6 +67,11 @@ export type BugstrConfig = { confirmSend?: (summary: BugstrSummary) => Promise | boolean; /** If true, uses native Alert for confirmation. Default: true */ useNativeAlert?: boolean; + /** + * Progress callback for large crash reports (>50KB). + * Fires asynchronously during upload - does not block the UI. + */ + onProgress?: BugstrProgressCallback; }; export type BugstrPayload = { @@ -77,6 +109,9 @@ let config: BugstrConfig = { useNativeAlert: true, }; +/** Track last post time per relay for rate limiting. */ +const lastPostTime: Map = new Map(); + // Helpers function decodePubkey(pubkey: string): string { if (!pubkey) return ''; @@ -130,35 +165,34 @@ function buildPayload(err: unknown, errorInfo?: ErrorInfo): BugstrPayload { }; } -async function sendToNostr(payload: BugstrPayload): Promise { - if (!developerPubkeyHex || !senderPrivkey) { - throw new Error('Bugstr Nostr keys not configured'); - } - - const relays = config.relays?.length ? config.relays : DEFAULT_RELAYS; - const plaintext = JSON.stringify(payload); - - // Build unsigned kind 14 (rumor) +/** + * Build a NIP-17 gift-wrapped event for a rumor. + */ +function buildGiftWrap( + rumorKind: number, + content: string, + senderPrivkey: Uint8Array, + recipientPubkey: string +): ReturnType { + // NIP-59: rumor uses actual timestamp, only seal/gift-wrap are randomized const rumorEvent: UnsignedEvent = { - kind: 14, - created_at: randomPastTimestamp(), - tags: [['p', developerPubkeyHex]], - content: plaintext, + kind: rumorKind, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', recipientPubkey]], + content, pubkey: getPublicKey(senderPrivkey), }; - // Compute rumor ID per NIP-01 using nostr-tools getEventHash const rumorId = getEventHash(rumorEvent); - - const unsignedKind14 = { + const unsignedRumor = { ...rumorEvent, id: rumorId, sig: '', // Empty signature for rumors per NIP-17 }; // Seal (kind 13) - const conversationKey = nip44.getConversationKey(senderPrivkey, developerPubkeyHex); - const sealContent = nip44.encrypt(JSON.stringify(unsignedKind14), conversationKey); + const conversationKey = nip44.getConversationKey(senderPrivkey, recipientPubkey); + const sealContent = nip44.encrypt(JSON.stringify(unsignedRumor), conversationKey); const seal = finalizeEvent( { kind: 13, @@ -171,24 +205,53 @@ async function sendToNostr(payload: BugstrPayload): Promise { // Gift wrap (kind 1059) const wrapperPrivBytes = generateSecretKey(); - const wrapKey = nip44.getConversationKey(wrapperPrivBytes, developerPubkeyHex); + const wrapKey = nip44.getConversationKey(wrapperPrivBytes, recipientPubkey); const giftWrapContent = nip44.encrypt(JSON.stringify(seal), wrapKey); - const giftWrap = finalizeEvent( + return finalizeEvent( { kind: 1059, created_at: randomPastTimestamp(), - tags: [['p', developerPubkeyHex]], + tags: [['p', recipientPubkey]], content: giftWrapContent, }, wrapperPrivBytes ); +} + +/** + * Build a public chunk event (kind 10422). + */ +function buildChunkEvent(chunk: ChunkData): ReturnType { + const chunkPrivkey = generateSecretKey(); + const chunkPayloadData: ChunkPayload = { + v: 1, + index: chunk.index, + hash: chunk.hash, + data: encodeChunkData(chunk), + }; + return finalizeEvent( + { + kind: KIND_CHUNK, + created_at: randomPastTimestamp(), + tags: [], + content: JSON.stringify(chunkPayloadData), + }, + chunkPrivkey + ); +} - // Publish +/** + * Publish an event to the first successful relay. + */ +async function publishToRelays( + relays: string[], + event: ReturnType +): Promise { let lastError: Error | undefined; for (const relayUrl of relays) { try { const relay = await Relay.connect(relayUrl); - await relay.publish(giftWrap); + await relay.publish(event); relay.close(); return; } catch (err) { @@ -198,6 +261,208 @@ async function sendToNostr(payload: BugstrPayload): Promise { throw lastError || new Error('Unable to publish Bugstr event'); } +/** + * Publish an event to all relays (for chunk redundancy). + */ +async function publishToAllRelays( + relays: string[], + event: ReturnType +): Promise { + const results = await Promise.allSettled( + relays.map(async (relayUrl) => { + const relay = await Relay.connect(relayUrl); + await relay.publish(event); + relay.close(); + }) + ); + + const successful = results.filter((r) => r.status === 'fulfilled').length; + if (successful === 0) { + throw new Error('Unable to publish chunk to any relay'); + } +} + +/** + * Wait for relay rate limit if needed. + */ +async function waitForRateLimit(relayUrl: string): Promise { + const rateLimit = getRelayRateLimit(relayUrl); + const lastTime = lastPostTime.get(relayUrl) ?? 0; + const now = Date.now(); + const elapsed = now - lastTime; + + if (elapsed < rateLimit) { + const waitMs = rateLimit - elapsed; + console.log(`Bugstr: rate limit wait ${waitMs}ms for ${relayUrl}`); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } +} + +/** + * Record post time for rate limiting. + */ +function recordPostTime(relayUrl: string): void { + lastPostTime.set(relayUrl, Date.now()); +} + +/** + * Publish chunk to a single relay with rate limiting. + */ +async function publishChunkToRelay( + relayUrl: string, + event: ReturnType +): Promise { + await waitForRateLimit(relayUrl); + const relay = await Relay.connect(relayUrl); + await relay.publish(event); + relay.close(); + recordPostTime(relayUrl); +} + +/** + * Verify a chunk event exists on a relay. + */ +async function verifyChunkExists(relayUrl: string, eventId: string): Promise { + try { + const relay = await Relay.connect(relayUrl); + const events = await relay.list([{ ids: [eventId], kinds: [KIND_CHUNK], limit: 1 }]); + relay.close(); + return events.length > 0; + } catch (err) { + console.log(`Bugstr: verify chunk failed on ${relayUrl}: ${err}`); + return false; + } +} + +/** + * Publish chunk with verification and retry on failure. + * @returns The relay URL where the chunk was successfully published, or null if all failed. + */ +async function publishChunkWithVerify( + event: ReturnType, + relays: string[], + startIndex: number +): Promise { + const numRelays = relays.length; + + // Try each relay starting from startIndex (round-robin) + for (let attempt = 0; attempt < numRelays; attempt++) { + const relayUrl = relays[(startIndex + attempt) % numRelays]; + + try { + // Publish with rate limiting + await publishChunkToRelay(relayUrl, event); + + // Brief delay before verification to allow relay to process + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verify the chunk exists + if (await verifyChunkExists(relayUrl, event.id)) { + return relayUrl; + } + console.log(`Bugstr: chunk verification failed on ${relayUrl}, trying next`); + } catch (err) { + console.log(`Bugstr: chunk publish failed on ${relayUrl}: ${err}`); + } + // Try next relay + } + + return null; // All relays failed +} + +/** + * Send payload via NIP-17 gift wrap, using chunking for large payloads. + * Uses round-robin relay distribution to maximize throughput while + * respecting per-relay rate limits (8 posts/min for strfry+noteguard). + */ +async function sendToNostr(payload: BugstrPayload): Promise { + if (!developerPubkeyHex || !senderPrivkey) { + throw new Error('Bugstr Nostr keys not configured'); + } + + const relays = config.relays?.length ? config.relays : DEFAULT_RELAYS; + const plaintext = JSON.stringify(payload); + const payloadSize = new TextEncoder().encode(plaintext).length; + const transportKind = getTransportKind(payloadSize); + + if (transportKind === 'direct') { + // Small payload: direct gift-wrapped delivery (no progress needed) + const directPayload = createDirectPayload(payload as Record); + const giftWrap = buildGiftWrap( + KIND_DIRECT, + JSON.stringify(directPayload), + senderPrivkey, + developerPubkeyHex + ); + + await publishToRelays(relays, giftWrap); + console.log('Bugstr: sent direct crash report'); + } else { + // Large payload: chunked delivery with round-robin distribution + console.log(`Bugstr: payload ${payloadSize} bytes, using chunked transport`); + + const { rootHash, totalSize, chunks } = chunkPayload(plaintext); + const totalChunks = chunks.length; + console.log(`Bugstr: split into ${totalChunks} chunks across ${relays.length} relays`); + + // Report initial progress + const estimatedSeconds = estimateUploadSeconds(totalChunks, relays.length); + config.onProgress?.(progressPreparing(totalChunks, estimatedSeconds)); + + // Build chunk events and track relay assignments with verification + const chunkEvents = chunks.map(buildChunkEvent); + const chunkIds: string[] = []; + const chunkRelays: Record = {}; + + for (let i = 0; i < chunkEvents.length; i++) { + const chunkEvent = chunkEvents[i]; + chunkIds.push(chunkEvent.id); + + // Publish with verification and retry (starts at round-robin relay) + const successRelay = await publishChunkWithVerify(chunkEvent, relays, i % relays.length); + if (successRelay) { + chunkRelays[chunkEvent.id] = [successRelay]; + } else { + // All relays failed for this chunk - fail fast to avoid partial uploads + console.error(`Bugstr: failed to publish chunk ${i}/${totalChunks} (id: ${chunkEvent.id})`); + throw new Error(`Failed to publish chunk ${i} to any relay after retries`); + } + + // Report progress + const remainingChunks = totalChunks - i - 1; + const remainingSeconds = estimateUploadSeconds(remainingChunks, relays.length); + config.onProgress?.(progressUploading(i + 1, totalChunks, remainingSeconds)); + } + console.log(`Bugstr: published ${totalChunks} chunks`); + + // Report finalizing + config.onProgress?.(progressFinalizing(totalChunks)); + + // Build and publish manifest with relay hints + const manifest: ManifestPayload = { + v: 1, + root_hash: rootHash, + total_size: totalSize, + chunk_count: totalChunks, + chunk_ids: chunkIds, + chunk_relays: chunkRelays, + }; + + const manifestGiftWrap = buildGiftWrap( + KIND_MANIFEST, + JSON.stringify(manifest), + senderPrivkey, + developerPubkeyHex + ); + + await publishToRelays(relays, manifestGiftWrap); + console.log('Bugstr: sent chunked crash report manifest'); + + // Report complete + config.onProgress?.(progressCompleted(totalChunks)); + } +} + async function nativeConfirm(summary: BugstrSummary): Promise { return new Promise((resolve) => { Alert.alert( diff --git a/react-native/src/transport.ts b/react-native/src/transport.ts new file mode 100644 index 0000000..b233451 --- /dev/null +++ b/react-native/src/transport.ts @@ -0,0 +1,165 @@ +/** + * Transport layer constants and types for crash report delivery. + * + * Supports both direct delivery (<=50KB) and hashtree-based chunked + * delivery (>50KB) for large crash reports. + */ + +/** Event kind for direct crash report delivery (<=50KB). */ +export const KIND_DIRECT = 10420; + +/** Event kind for hashtree manifest (>50KB crash reports). */ +export const KIND_MANIFEST = 10421; + +/** Event kind for CHK-encrypted chunk data. */ +export const KIND_CHUNK = 10422; + +/** Size threshold for switching from direct to chunked transport (50KB). */ +export const DIRECT_SIZE_THRESHOLD = 50 * 1024; + +/** Maximum chunk size (48KB, accounts for base64 + relay overhead). */ +export const MAX_CHUNK_SIZE = 48 * 1024; + +// --------------------------------------------------------------------------- +// Relay Rate Limiting +// --------------------------------------------------------------------------- + +/** + * Known relay rate limits in milliseconds between posts. + * Based on strfry + noteguard default: 8 posts/minute = 7500ms between posts. + */ +export const RELAY_RATE_LIMITS: Record = { + 'wss://relay.damus.io': 7500, + 'wss://nos.lol': 7500, + 'wss://relay.primal.net': 7500, +}; + +/** Default rate limit for unknown relays (conservative: 8 posts/min). */ +export const DEFAULT_RELAY_RATE_LIMIT = 7500; + +/** Get rate limit for a relay URL. */ +export function getRelayRateLimit(relayUrl: string): number { + return RELAY_RATE_LIMITS[relayUrl] ?? DEFAULT_RELAY_RATE_LIMIT; +} + +/** Estimate upload time in seconds for given chunks and relays. */ +export function estimateUploadSeconds(totalChunks: number, numRelays: number): number { + const msPerChunk = DEFAULT_RELAY_RATE_LIMIT / numRelays; + return Math.ceil((totalChunks * msPerChunk) / 1000); +} + +// --------------------------------------------------------------------------- +// Progress Reporting (Apple HIG Compliant) +// --------------------------------------------------------------------------- + +/** Phase of crash report upload. */ +export type BugstrProgressPhase = 'preparing' | 'uploading' | 'finalizing'; + +/** + * Progress state for crash report upload. + * Designed for HIG-compliant determinate progress indicators. + */ +export type BugstrProgress = { + /** Current phase of upload. */ + phase: BugstrProgressPhase; + /** Current chunk being uploaded (1-indexed for display). */ + currentChunk: number; + /** Total number of chunks. */ + totalChunks: number; + /** Progress as fraction 0.0 to 1.0 (for ProgressView). */ + fractionCompleted: number; + /** Estimated seconds remaining. */ + estimatedSecondsRemaining: number; + /** Human-readable status for accessibility/display. */ + localizedDescription: string; +}; + +/** Callback type for progress updates. */ +export type BugstrProgressCallback = (progress: BugstrProgress) => void; + +/** Create progress for preparing phase. */ +export function progressPreparing(totalChunks: number, estimatedSeconds: number): BugstrProgress { + return { + phase: 'preparing', + currentChunk: 0, + totalChunks, + fractionCompleted: 0, + estimatedSecondsRemaining: estimatedSeconds, + localizedDescription: 'Preparing crash report...', + }; +} + +/** Create progress for uploading phase. */ +export function progressUploading(current: number, total: number, estimatedSeconds: number): BugstrProgress { + return { + phase: 'uploading', + currentChunk: current, + totalChunks: total, + fractionCompleted: (current / total) * 0.95, + estimatedSecondsRemaining: estimatedSeconds, + localizedDescription: `Uploading chunk ${current} of ${total}`, + }; +} + +/** Create progress for finalizing phase. */ +export function progressFinalizing(totalChunks: number): BugstrProgress { + return { + phase: 'finalizing', + currentChunk: totalChunks, + totalChunks, + fractionCompleted: 0.95, + estimatedSecondsRemaining: 2, + localizedDescription: 'Finalizing...', + }; +} + +/** Create progress for completion. */ +export function progressCompleted(totalChunks: number): BugstrProgress { + return { + phase: 'finalizing', + currentChunk: totalChunks, + totalChunks, + fractionCompleted: 1.0, + estimatedSecondsRemaining: 0, + localizedDescription: 'Complete', + }; +} + +// --------------------------------------------------------------------------- +// Payload Types +// --------------------------------------------------------------------------- + +/** Direct crash report payload (kind 10420). */ +export type DirectPayload = { + v: number; + crash: Record; +}; + +/** Hashtree manifest payload (kind 10421). */ +export type ManifestPayload = { + v: number; + root_hash: string; + total_size: number; + chunk_count: number; + chunk_ids: string[]; + /** Optional relay hints for each chunk (for optimized fetching). */ + chunk_relays?: Record; +}; + +/** Chunk payload (kind 10422). */ +export type ChunkPayload = { + v: number; + index: number; + hash: string; + data: string; +}; + +/** Determines transport kind based on payload size. */ +export function getTransportKind(size: number): 'direct' | 'chunked' { + return size <= DIRECT_SIZE_THRESHOLD ? 'direct' : 'chunked'; +} + +/** Creates a direct payload wrapper. */ +export function createDirectPayload(crash: Record): DirectPayload { + return { v: 1, crash }; +} diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index f77f0d6..6989002 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Transport module for crash report delivery with new event kinds: + - Kind 10420: Direct crash report transport (≤50KB payloads) + - Kind 10421: Hashtree manifest for large crash reports + - Kind 10422: CHK-encrypted chunk data +- CHK (Content Hash Key) chunking module for large payload support: + - `chunk_payload()` splits and encrypts payloads using CHK encryption + - `reassemble_payload()` decrypts and reconstructs original data + - Root hash computed from chunk keys ensures integrity + - Secure when manifest delivered via NIP-17 gift wrap +- Receiver now supports kind 10420 in addition to legacy kind 14 +- Receiver fetches and reassembles chunked crash reports from kind 10421 manifests +- `DirectPayload`, `ManifestPayload`, `ChunkPayload` types for transport layer +- `TransportKind` enum for automatic transport selection based on payload size +- `hashtree-core` dependency for CHK encryption primitives - Symbolication module with support for 7 platforms: - Android (ProGuard/R8 mapping.txt parsing) - JavaScript/Electron (source map support) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 5d6a559..118e631 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -47,4 +47,7 @@ sourcemap = "9.0" tempfile = "3.14" semver = "1.0" +# Hashtree for large payload chunking (Phase 2) +hashtree-core = "0.2" + [dev-dependencies] diff --git a/rust/README.md b/rust/README.md index 0f58dfe..8f1dcfd 100644 --- a/rust/README.md +++ b/rust/README.md @@ -97,6 +97,72 @@ let json = event.to_json(); Rumors include `id` (computed) and `sig: ""` (empty string) per spec. +## Symbolication + +Bugstr supports server-side symbolication of stack traces using mapping files (ProGuard, source maps, etc.). + +### Enable Symbolication + +```bash +# Start server with mappings directory +bugstr serve --privkey $BUGSTR_PRIVKEY --mappings ./mappings + +# Or via CLI +bugstr symbolicate --mappings ./mappings --platform android --app com.example.app --version 1.0.0 < stacktrace.txt +``` + +### Directory Structure + +Mapping files are organized by platform, app ID, and version: + +```text +mappings/ + android/ + com.example.app/ + 1.0.0/ + mapping.txt # ProGuard/R8 mapping + 1.1.0/ + mapping.txt + electron/ + my-desktop-app/ + 1.0.0/ + main.js.map # Source map + renderer.js.map + flutter/ + com.example.app/ + 1.0.0/ + app.android-arm64.symbols + react-native/ + com.example.app/ + 1.0.0/ + index.android.bundle.map +``` + +### Supported Platforms + +| Platform | Mapping File | Notes | +|----------|-------------|-------| +| Android | `mapping.txt` | ProGuard/R8 obfuscation mapping | +| Electron/JS | `*.js.map` | Source maps | +| Flutter | `*.symbols` | Flutter symbolize format | +| React Native | `*.bundle.map` | Hermes bytecode + JS source maps | +| Rust | — | Parses native backtraces | +| Go | — | Parses goroutine stacks | +| Python | — | Parses tracebacks | + +### API Endpoint + +```bash +curl -X POST http://localhost:3000/api/symbolicate \ + -H "Content-Type: application/json" \ + -d '{ + "platform": "android", + "app_id": "com.example.app", + "version": "1.0.0", + "stack_trace": "..." + }' +``` + ## Other Platforms - [Android/Kotlin](../android/) diff --git a/rust/docs/CHUNK_DISTRIBUTION_DESIGN.md b/rust/docs/CHUNK_DISTRIBUTION_DESIGN.md new file mode 100644 index 0000000..2f38df4 --- /dev/null +++ b/rust/docs/CHUNK_DISTRIBUTION_DESIGN.md @@ -0,0 +1,355 @@ +# Chunk Distribution & Rate Limiting Design + +Bead: `rust-0qz` - Implement relay-specific rate limiting and progress UX for chunk uploads + +## Problem + +All three default relays use strfry with noteguard rate limiting: +- **relay.damus.io**: strfry + noteguard +- **nos.lol**: strfry + noteguard +- **relay.primal.net**: strfry + noteguard + +**Default rate limit: 8 posts/minute per IP** (from noteguard.toml) + +### Current Implementation Issues + +1. Current 100ms delay is **way too fast** (would allow 600/min vs limit of 8/min) +2. Publishing to ALL relays for each chunk means hitting rate limits on ALL relays +3. No progress feedback to users +4. No relay hints for receiver optimization + +### UX Impact (Current: Single Relay @ 8/min) + +| Payload | Chunks | Events | Time | UX | +|---------|--------|--------|------|-----| +| 50KB | 0 | 1 | instant | ✅ | +| 100KB | 3 | 4 | **30 sec** | 😐 | +| 500KB | 11 | 12 | **90 sec** | 😬 | +| 1MB | 22 | 23 | **~3 min** | ❌ | + +## Proposed Solution: Round-Robin Distribution with Relay Hints + +### Strategy + +Distribute chunks across relays in round-robin fashion: +``` +Chunk 0 → relay.damus.io +Chunk 1 → nos.lol +Chunk 2 → relay.primal.net +Chunk 3 → relay.damus.io (cycle) +... +``` + +### Extended Manifest with Relay Hints + +```json +{ + "v": 1, + "root_hash": "abc123...", + "total_size": 100000, + "chunk_count": 3, + "chunk_ids": ["id0", "id1", "id2"], + "chunk_relays": { + "id0": ["wss://relay.damus.io"], + "id1": ["wss://nos.lol"], + "id2": ["wss://relay.primal.net"] + } +} +``` + +### UX Impact (3-Relay Distribution @ 8/min each = 24/min effective) + +| Payload | Chunks | Time (old) | Time (new) | Improvement | +|---------|--------|------------|------------|-------------| +| 100KB | 3 | 30 sec | **7.5 sec** | 4x faster | +| 500KB | 11 | 90 sec | **27 sec** | 3.3x faster | +| 1MB | 22 | 3 min | **55 sec** | 3.3x faster | + +### Rate Limit Configuration + +```typescript +const RELAY_RATE_LIMITS: Record = { + // Known strfry + noteguard relays: 8 posts/min = 7500ms between posts + 'wss://relay.damus.io': 7500, + 'wss://nos.lol': 7500, + 'wss://relay.primal.net': 7500, + // Default for unknown relays (conservative) + 'default': 7500, +}; +``` + +### Progress Callback API (Apple HIG Compliant) + +Per [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/progress-indicators): + +1. **Use determinate progress** - Since chunk count is known, show exact progress (not spinner) +2. **Show estimated time remaining** - Help users gauge duration +3. **Avoid vague terms** - "Uploading chunk 3 of 22" not just "Loading..." +4. **Show progress immediately** - Don't leave screen blank/frozen + +```typescript +/** + * Progress state for crash report upload. + * Designed for HIG-compliant determinate progress indicators. + */ +export type BugstrProgress = { + /** Current phase: 'preparing' | 'uploading' | 'finalizing' */ + phase: 'preparing' | 'uploading' | 'finalizing'; + + /** Current chunk being uploaded (1-indexed for display) */ + currentChunk: number; + + /** Total number of chunks */ + totalChunks: number; + + /** Progress as fraction 0.0 to 1.0 (for UIProgressView/ProgressView) */ + fractionCompleted: number; + + /** Estimated seconds remaining (for display) */ + estimatedSecondsRemaining: number; + + /** Human-readable status for accessibility/display */ + localizedDescription: string; +}; + +// Callback type +export type BugstrProgressCallback = (progress: BugstrProgress) => void; + +// Usage - Flutter example with HIG-compliant UI +Bugstr.init( + developerPubkey: 'npub1...', + onProgress: (progress) { + setState(() { + _uploadProgress = progress.fractionCompleted; + _statusText = progress.localizedDescription; + _timeRemaining = progress.estimatedSecondsRemaining; + }); + }, +); + +// Example progress states: +// { phase: 'preparing', currentChunk: 0, totalChunks: 22, fractionCompleted: 0.0, +// estimatedSecondsRemaining: 55, localizedDescription: 'Preparing crash report...' } +// +// { phase: 'uploading', currentChunk: 5, totalChunks: 22, fractionCompleted: 0.23, +// estimatedSecondsRemaining: 42, localizedDescription: 'Uploading chunk 5 of 22' } +// +// { phase: 'finalizing', currentChunk: 22, totalChunks: 22, fractionCompleted: 0.95, +// estimatedSecondsRemaining: 2, localizedDescription: 'Finalizing...' } +``` + +### Recommended UI Implementation + +```dart +// Flutter - HIG-compliant progress indicator +Widget buildProgressIndicator(BugstrProgress progress) { + return Column( + children: [ + // Determinate progress bar (not CircularProgressIndicator) + LinearProgressIndicator( + value: progress.fractionCompleted, + semanticsLabel: progress.localizedDescription, + ), + SizedBox(height: 8), + // Status text + Text(progress.localizedDescription), + // Time remaining (if > 5 seconds) + if (progress.estimatedSecondsRemaining > 5) + Text('About ${progress.estimatedSecondsRemaining} seconds remaining'), + ], + ); +} +``` + +```swift +// SwiftUI - HIG-compliant progress indicator +struct UploadProgressView: View { + let progress: BugstrProgress + + var body: some View { + VStack { + ProgressView(value: progress.fractionCompleted) + .progressViewStyle(.linear) + + Text(progress.localizedDescription) + .font(.caption) + + if progress.estimatedSecondsRemaining > 5 { + Text("About \(progress.estimatedSecondsRemaining) seconds remaining") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } +} +``` + +## Implementation Changes + +### 1. Transport Layer Updates + +Add to `transport.ts` / `Transport.kt` / etc: +```typescript +// Per-relay rate limiting (ms between posts) +export const RELAY_RATE_LIMITS: Record = { + 'wss://relay.damus.io': 7500, + 'wss://nos.lol': 7500, + 'wss://relay.primal.net': 7500, + 'default': 7500, +}; + +// Get rate limit for a relay +export function getRelayRateLimit(relayUrl: string): number { + return RELAY_RATE_LIMITS[relayUrl] ?? RELAY_RATE_LIMITS['default']; +} +``` + +### 2. Manifest Payload Extension + +```typescript +export type ManifestPayload = { + v: number; + root_hash: string; + total_size: number; + chunk_count: number; + chunk_ids: string[]; + chunk_relays?: Record; // NEW: relay hints per chunk +}; +``` + +### 3. Sender: Round-Robin with Rate Tracking + +```typescript +async function sendChunked(payload: CrashPayload, onProgress?: ChunkProgressCallback) { + const relays = config.relays; + const lastPostTime: Map = new Map(); + const chunkRelays: Record = {}; + + for (let i = 0; i < chunks.length; i++) { + const relayUrl = relays[i % relays.length]; // Round-robin + + // Wait for rate limit + const lastTime = lastPostTime.get(relayUrl) ?? 0; + const rateLimit = getRelayRateLimit(relayUrl); + const elapsed = Date.now() - lastTime; + if (elapsed < rateLimit) { + await sleep(rateLimit - elapsed); + } + + // Publish chunk + await publishToRelay(relayUrl, chunkEvent); + lastPostTime.set(relayUrl, Date.now()); + + // Track relay hint + chunkRelays[chunkEvent.id] = [relayUrl]; + + // Report progress + onProgress?.({ + phase: 'uploading', + current: i + 1, + total: chunks.length, + percent: Math.round((i + 1) / chunks.length * 100), + estimatedSecondsRemaining: (chunks.length - i - 1) * (rateLimit / relays.length) / 1000, + }); + } + + // Include relay hints in manifest + const manifest = { ..., chunk_relays: chunkRelays }; +} +``` + +### 4. Receiver: Use Relay Hints + +```rust +async fn fetch_chunks(manifest: &Manifest, default_relays: &[String]) -> Result> { + for chunk_id in &manifest.chunk_ids { + // Prefer relay hints if available + let relays = manifest.chunk_relays + .as_ref() + .and_then(|hints| hints.get(chunk_id)) + .unwrap_or(default_relays); + + // Try hinted relays first, then fall back to all relays + let chunk = fetch_from_relays(chunk_id, relays).await?; + } +} +``` + +## Reliability Analysis + +With 4 relays and publish verification + retry, each chunk gets 4 attempts before failing. + +**Math:** +- p = single relay failure rate (1 - reliability) +- P(chunk fails) = p⁴ (all 4 relays must fail) +- P(report fails) = 1 - (1 - p⁴)³⁰ (at least 1 of 30 chunks lost) + +| Relay Reliability | Single Relay Failure | Chunk Failure (p⁴) | 30-Chunk Report Success | +|-------------------|---------------------|-------------------|------------------------| +| 80% | 20% | 0.16% | **95.3%** | +| 90% | 10% | 0.01% | **99.7%** | +| 95% | 5% | 0.000625% | **99.98%** | +| 98% | 2% | 0.000016% | **99.9995%** | + +The 4-relay retry provides exponential improvement. Even with 80% individual relay reliability, +a 30-chunk report has 95%+ success rate. With typical relay reliability (95%+), failure is +effectively negligible. + +**Future Enhancement:** Query [nostr.watch](https://nostr.watch) for real-time relay uptime +and dynamically select most reliable relays. + +### Reliability Enhancement Options + +| Approach | Description | Reliability | Upload Time | Bandwidth | Complexity | Works While Crashing | +|----------|-------------|-------------|-------------|-----------|------------|---------------------| +| **Current (verify+retry)** | Publish, verify, retry on different relay | 99.98% @ 95% relay | 1x | 1x | Low | ✅ Yes | +| **Redundant Publishing** | Publish each chunk to 2 relays | 99.9999% @ 95% relay | 2x | 2x | Low | ✅ Yes | +| **Erasure Coding** | 30 data + 10 parity chunks, need any 30 | 99.9999%+ (tolerates 25% loss) | 1.33x | 1.33x | High | ✅ Yes | +| **Bidirectional Requests** | Receiver asks sender for missing chunks | ~100% (if sender online) | 1x + retry | 1x + retry | Medium | ❌ No | +| **Hybrid (optional bidir)** | Fire-and-forget + optional 60s listen | 99.98% → ~100% | 1x + optional | 1x + optional | Medium | ⚠️ Partial | + +**Recommendation:** Current approach (verify+retry) provides 99.98% reliability with minimal complexity. +Consider erasure coding if higher reliability needed without bidirectional communication. + +## Redundancy Considerations + +**Option A: Single relay per chunk (fastest, less redundant)** +- Each chunk goes to 1 relay +- Risk: If relay goes down, chunk is lost +- Mitigation: Publish verification + retry across all 4 relays + +**Option B: Two relays per chunk (balanced)** +- Each chunk goes to 2 relays (staggered round-robin) +- Better redundancy, slightly slower +- Example: chunk 0 → [damus, nos.lol], chunk 1 → [nos.lol, primal] + +**Recommendation: Option A with verification** - Publish verification + retry provides +sufficient resilience without the overhead of dual publishing. + +## Files to Modify + +### SDKs (Senders) +- `dart/lib/src/bugstr_client.dart` +- `dart/lib/src/transport.dart` +- `android/.../Nip17CrashSender.kt` +- `android/.../Transport.kt` +- `electron/src/sdk.ts` +- `electron/src/transport.ts` +- `react-native/src/index.ts` +- `react-native/src/transport.ts` +- `go/bugstr.go` +- `python/bugstr/__init__.py` + +### Receiver +- `rust/src/bin/main.rs` - Use relay hints when fetching + +### Types +- All `ManifestPayload` types need `chunk_relays` field + +## Testing Plan + +1. Unit test: Round-robin distribution logic +2. Unit test: Rate limit waiting logic +3. Integration test: Send 100KB payload, verify timing +4. Integration test: Send 500KB payload, verify progress callbacks +5. Integration test: Receiver can fetch with/without relay hints diff --git a/rust/docs/NIP17_NIP59_AUDIT.md b/rust/docs/NIP17_NIP59_AUDIT.md new file mode 100644 index 0000000..c50b885 --- /dev/null +++ b/rust/docs/NIP17_NIP59_AUDIT.md @@ -0,0 +1,289 @@ +# NIP-17 / NIP-59 Compliance Audit + +**Date:** 2026-01-16 +**Scope:** All bugstr SDKs (Android, Go, Python, React Native, Dart, Electron) and Rust receiver + +## Executive Summary + +The bugstr implementation is **largely compliant** with NIP-17 and NIP-59, with several gaps identified in the Rust receiver and optional feature support across sender SDKs. + +### Critical Gaps +1. **Rust receiver doesn't verify signatures** - relies on implicit nostr crate behavior (which doesn't auto-verify) +2. **Rust receiver doesn't validate seal kind == 13** + +### Medium Gaps +3. **All SDKs randomize rumor timestamp** - NIP-59 says rumor should have canonical timestamp; only seal/gift-wrap should be randomized +4. **No sender gift-wrap** - NIP-17 requires messages be gift-wrapped to BOTH sender and recipients; SDKs only wrap to recipient +5. **Android seal has tags** - NIP-59 says seal tags MUST be empty; Android adds expiration tags to seal +6. Non-Android SDKs don't support reply threading (e-tag), subject tags, expiration tags + +### Low Gaps +7. **No kind:10050 relay lookup** - NIP-17 SHOULD consult recipient's relay list; SDKs use configured/default relays + +--- + +## NIP-59 Gift Wrap Structure + +### Rumor (Unsigned Inner Event) + +| SDK | Kind Used | ID Computed | sig="" | Timestamp Randomized | +|-----|-----------|-------------|--------|----------------------| +| Android | 14 (chat) | ✅ SHA256 | ✅ | ⚠️ ±2 days (VIOLATION) | +| Go | 10420/10421 | ✅ SHA256 | ✅ | ⚠️ ±2 days (VIOLATION) | +| Python | 10420/10421 | ✅ SHA256 | ✅ | ⚠️ ±2 days (VIOLATION) | +| React Native | 10420/10421 | ✅ getEventHash | ✅ | ⚠️ ±2 days (VIOLATION) | +| Dart | 10420/10421 | ✅ SHA256 | ✅ | ⚠️ ±2 days (VIOLATION) | +| Electron | 10420/10421 | ✅ getEventHash | ✅ | ⚠️ ±2 days (VIOLATION) | + +**⚠️ SPEC VIOLATION:** All SDKs randomize the rumor's `created_at`. NIP-59 specifies that the rumor should contain the canonical message timestamp, while only the seal and gift-wrap timestamps should be randomized for privacy. This breaks timing semantics. + +**Locations:** +- `android/.../Nip17PayloadBuilder.kt:32-37` - `rumor.copy(createdAt = createdAt)` where `createdAt` is randomized +- `go/bugstr.go:491-494` - rumor created_at = randomPastTimestamp() +- `python/bugstr/__init__.py:456-458` - rumor created_at = _random_past_timestamp() +- `react-native/src/index.ts:177-179` - rumor created_at = randomPastTimestamp() +- `dart/lib/src/bugstr_client.dart:199` - rumorCreatedAt = _randomPastTimestamp() +- `electron/src/sdk.ts:170-172` - rumor created_at = randomPastTimestamp() + +**Note:** Android wraps DirectPayload inside a kind 14 rumor (per NIP-17), while other SDKs use kind 10420/10421 directly. The Rust receiver accepts both (`is_crash_report_kind` matches 14, 10420, 10421). + +### Seal (Kind 13) + +| SDK | Kind | Signed By | Encryption | Timestamp | Tags Empty | +|-----|------|-----------|------------|-----------|------------| +| Android | 13 ✅ | Sender privkey ✅ | NIP-44 ✅ | Randomized ✅ | ⚠️ NO (VIOLATION) | +| Go | 13 ✅ | Sender privkey ✅ | NIP-44 ✅ | Randomized ✅ | ✅ | +| Python | 13 ✅ | Sender keys ✅ | NIP-44 ✅ | Randomized ✅ | ✅ | +| React Native | 13 ✅ | Sender privkey ✅ | NIP-44 ✅ | Randomized ✅ | ✅ | +| Dart | 13 ✅ | Sender privkey ✅ | NIP-44 ✅ | Randomized ✅ | ✅ | +| Electron | 13 ✅ | Sender privkey ✅ | NIP-44 ✅ | Randomized ✅ | ✅ | + +**⚠️ SPEC VIOLATION (Android):** NIP-59 says seal "tags MUST be empty". Android adds expiration tags to the seal when `expirationSeconds` is set. + +**Location:** `android/.../Nip17PayloadBuilder.kt:173-175` +```kotlin +val sealTags = buildList { + expirationSeconds?.let { add(listOf("expiration", it.toString())) } +} +``` + +Expiration tags should only be on the gift wrap, not the seal. + +### Gift Wrap (Kind 1059) + +| SDK | Kind | Random Keypair | p-tag Recipient | Timestamp | +|-----|------|----------------|-----------------|-----------| +| Android | 1059 ✅ | ✅ randomPrivateKeyHex | ✅ | Randomized ✅ | +| Go | 1059 ✅ | ✅ GeneratePrivateKey | ✅ | Randomized ✅ | +| Python | 1059 ✅ | ✅ Keys.generate() | ✅ | Randomized ✅ | +| React Native | 1059 ✅ | ✅ generateSecretKey | ✅ | Randomized ✅ | +| Dart | 1059 ✅ | ✅ KeyPair.generate | ✅ | Randomized ✅ | +| Electron | 1059 ✅ | ✅ generateSecretKey | ✅ | Randomized ✅ | + +--- + +## NIP-17 Private Direct Messages + +### Sender Gift-Wrap Requirement + +**⚠️ SPEC VIOLATION (All SDKs):** NIP-17 states: "Messages MUST be gift-wrapped to each receiver **and the sender individually**, so the sender can read and process their own sent messages from relays." + +All bugstr SDKs only gift-wrap to recipients, not to the sender: + +| SDK | Gift-wraps to Sender | Location | +|-----|---------------------|----------| +| Android | ❌ | `Nip17PayloadBuilder.kt:35` - only maps over `recipients` | +| Go | ❌ | `bugstr.go:487-550` - single gift wrap to developer | +| Python | ❌ | `__init__.py:454-494` - single gift wrap to developer | +| React Native | ❌ | `index.ts:171-218` - single gift wrap to recipient | +| Dart | ❌ | `bugstr_client.dart:197-260` - single gift wrap to developer | +| Electron | ❌ | `sdk.ts:164-211` - single gift wrap to recipient | + +**Rationale for bugstr:** For crash reporting, the sender (crashing app) typically doesn't need to read back its own crash reports. However, this is technically a protocol violation. + +### Relay Discovery (kind:10050) + +**⚠️ SPEC RECOMMENDATION NOT FOLLOWED:** NIP-17 states: "Clients SHOULD read kind:10050 relay lists of the recipients to deliver messages." + +All SDKs use configured or default relays instead of consulting the recipient's relay list: + +| SDK | Consults 10050 | Location | +|-----|---------------|----------| +| Android | ❌ | Uses relays from `Nip17SendRequest` | +| Go | ❌ | `bugstr.go:720` - uses `config.Relays` | +| Python | ❌ | `__init__.py:512` - uses `_config.relays` | +| React Native | ❌ | `index.ts:382` - uses `config.relays` | +| Dart | ❌ | `bugstr_client.dart:282` - uses `effectiveRelays` | +| Electron | ❌ | `sdk.ts:240` - uses `config.relays` | + +**Impact:** Crash reports may not reach developers who only monitor their preferred relays listed in kind:10050. + +### Tag Handling + +| SDK | Recipient p-tag | Sender NOT in p-tag | Reply e-tag | Subject tag | Expiration tag | +|-----|-----------------|---------------------|-------------|-------------|----------------| +| Android | ✅ | ✅ | ✅ | ✅ | ✅ | +| Go | ✅ | ✅ | ❌ | ❌ | ❌ | +| Python | ✅ | ✅ | ❌ | ❌ | ❌ | +| React Native | ✅ | ✅ | ❌ | ❌ | ❌ | +| Dart | ✅ | ✅ | ❌ | ❌ | ❌ | +| Electron | ✅ | ✅ | ❌ | ❌ | ❌ | + +**NIP-17 Spec:** "Senders must include p tags for all recipients in the rumor but SHOULD NOT include a p tag for themselves." + +All SDKs correctly exclude the sender from rumor p-tags. ✅ + +--- + +## Rust Receiver Analysis + +### Current Implementation (main.rs:1143-1152) + +```rust +fn unwrap_gift_wrap(keys: &Keys, gift_wrap: &Event) -> Result> { + // Decrypt gift wrap to get seal + let seal_json = nip44::decrypt(keys.secret_key(), &gift_wrap.pubkey, &gift_wrap.content)?; + let seal: Event = serde_json::from_str(&seal_json)?; + + // Decrypt seal to get rumor + let rumor_json = nip44::decrypt(keys.secret_key(), &seal.pubkey, &seal.content)?; + let rumor: Rumor = serde_json::from_str(&rumor_json)?; + + Ok(rumor) +} +``` + +### Gaps Identified + +1. **No Gift Wrap Signature Verification** + - The gift wrap arrives from relay as `Event` parsed by nostr crate + - `serde_json::from_str` does NOT verify signatures (confirmed in nostr-0.43 source) + - Should call `gift_wrap.verify()` before processing + - **Risk:** Malformed events could be processed + +2. **No Seal Signature Verification** + - Seal parsed via `serde_json::from_str` - no auto-verification + - Should call `seal.verify()` after parsing + - **Risk:** Tampered seals could be accepted + +3. **No Seal Kind Validation** + - Code doesn't check `seal.kind == 13` + - **Risk:** Any event kind inside gift wrap would be processed + +4. **Seal Sender Identity Not Logged/Displayed** + - NIP-59: "if the receiver can verify the seal signature, it can be sure the sender created the gift wrap" + - `seal.pubkey` is the actual sender identity - should be prominently logged + +### Recommended Fix + +```rust +fn unwrap_gift_wrap(keys: &Keys, gift_wrap: &Event) -> Result> { + // Verify gift wrap signature (from random keypair) + gift_wrap.verify()?; + + // Decrypt gift wrap to get seal + let seal_json = nip44::decrypt(keys.secret_key(), &gift_wrap.pubkey, &gift_wrap.content)?; + let seal: Event = serde_json::from_str(&seal_json)?; + + // Verify seal kind + if seal.kind != Kind::Seal { + return Err("Invalid seal kind".into()); + } + + // Verify seal signature (from actual sender) + seal.verify()?; + + // Log verified sender identity + tracing::info!("Verified sender: {}", seal.pubkey.to_hex()); + + // Decrypt seal to get rumor + let rumor_json = nip44::decrypt(keys.secret_key(), &seal.pubkey, &seal.content)?; + let rumor: Rumor = serde_json::from_str(&rumor_json)?; + + Ok(rumor) +} +``` + +--- + +## NIP-44 Encryption + +All SDKs correctly use NIP-44 versioned encryption for both seal and gift wrap content. ✅ + +| SDK | Library | +|-----|---------| +| Android | quartz.crypto (Nip44Encryptor) | +| Go | github.com/nbd-wtf/go-nostr/nip44 | +| Python | nostr_sdk.nip44 | +| React Native | nostr-tools.nip44 | +| Dart | ndk (Nip44) | +| Electron | nostr-tools.nip44 | + +--- + +## Timestamp Randomization + +All SDKs implement ±2 days randomization per NIP-59: + +| SDK | Implementation | +|-----|----------------| +| Android | `TimestampRandomizer` - random 0 to 2 days in past | +| Go | `randomPastTimestamp()` - random 0 to 2 days in past | +| Python | `_random_past_timestamp()` - random 0 to 2 days in past | +| React Native | `randomPastTimestamp()` - random 0 to 2 days in past | +| Dart | `_randomPastTimestamp()` - random 0 to 2 days in past | +| Electron | `randomPastTimestamp()` - random 0 to 2 days in past | + +**NIP-59 Spec:** "created_at SHOULD be tweaked to thwart time-analysis attacks. All inner event timestamps SHOULD be set to a date in the past within 2-day window." + +All implementations randomize into the past (not future), which is correct. ✅ + +--- + +## Custom Kinds (Bugstr Extension) + +Bugstr extends NIP-17 with custom kinds for crash report transport: + +| Kind | Purpose | Transport | +|------|---------|-----------| +| 10420 | Direct crash payload (≤50KB) | Gift-wrapped | +| 10421 | Manifest for chunked payload (>50KB) | Gift-wrapped | +| 10422 | CHK-encrypted chunk data | Public (decryptable only with manifest) | + +These are application-specific kinds and don't conflict with NIP-17/NIP-59. The receiver correctly filters for kind 1059 (gift wrap) and then examines the rumor kind to determine processing path. + +--- + +## Recommendations + +### Priority 1 (Critical) - FIXED 2026-01-16 +- [x] Add `gift_wrap.verify()` call in Rust receiver +- [x] Add `seal.verify()` call in Rust receiver +- [x] Add `seal.kind == 13` validation in Rust receiver +- [x] Add `seal.tags` empty validation (NIP-59) +- [x] Add `rumor.sig` empty validation (NIP-59) +- [x] Add `seal.pubkey == rumor.pubkey` validation (NIP-17: prevent sender spoofing) + +### Priority 2 (Medium) - Spec Violations - FIXED 2026-01-16 +- [x] **Fix rumor timestamp**: All SDKs now use actual message time for rumor `created_at`, only randomize seal/gift-wrap +- [x] **Fix Android seal tags**: Removed expiration tag from seal (kept on gift-wrap only) +- [x] **Sender gift-wrap**: Intentionally skipped - crash reporters don't need to read back their own reports (documented deviation from NIP-17 MUST) + +### Priority 3 (Feature Gaps) +- [ ] ~~Add reply threading support (e-tag) to Go, Python, RN, Dart, Electron SDKs~~ - Not needed for crash reporting +- [ ] ~~Add subject tag support to Go, Python, RN, Dart, Electron SDKs~~ - Not needed for crash reporting +- [x] Expiration tag support already present in Android SDK + +### Priority 4 (Low) +- [ ] Log verified sender pubkey prominently in receiver +- [ ] ~~Consider kind:10050 relay discovery for better deliverability~~ - Using hardcoded relay list +- [ ] Add protocol version field to allow future evolution +- [ ] Document the protocol inconsistency between Android (kind 14 wrapper) and other SDKs (direct kind 10420/10421) + +--- + +## Specification References + +- **NIP-17:** https://github.com/nostr-protocol/nips/blob/master/17.md +- **NIP-59:** https://github.com/nostr-protocol/nips/blob/master/59.md +- **NIP-44:** https://github.com/nostr-protocol/nips/blob/master/44.md diff --git a/rust/docs/RATE_LIMITING.md b/rust/docs/RATE_LIMITING.md new file mode 100644 index 0000000..92bddde --- /dev/null +++ b/rust/docs/RATE_LIMITING.md @@ -0,0 +1,142 @@ +# Relay Rate Limiting Analysis + +Investigation for bead `rust-x1v`: Relay rate limiting behavior when sending many chunks. + +## Background + +When sending large crash reports via CHK chunking, multiple chunk events (kind 10422) are published to relays in quick succession. This could trigger relay rate limiting. + +## Damus/strfry Rate Limiting + +The damus.io relay uses [noteguard](https://github.com/damus-io/noteguard), a plugin for strfry that implements rate limiting: + +### Configuration +- **Rate limit**: Configurable `posts_per_minute` (example: 8) +- **Scope**: Per IP address +- **Whitelist**: Specific IPs can bypass rate limiting +- **No burst allowance**: Simple per-minute threshold, no spike handling + +### Rejection Behavior +When rate limit is exceeded: +- Event is rejected +- Error message: "rate-limited: you are noting too much" +- Pipeline stops, event is not stored + +## Impact on Bugstr Chunking + +### Scenario: 100KB Crash Report +- Chunk size: 48KB +- Chunks needed: 3 +- Events to publish: 3 chunks + 1 manifest = 4 events + +### Scenario: 1MB Crash Report +- Chunk size: 48KB +- Chunks needed: 22 +- Events to publish: 22 chunks + 1 manifest = 23 events + +### Risk Assessment + +| Relay Type | Rate Limit | Risk for 3 chunks | Risk for 22 chunks | +|------------|-----------|-------------------|-------------------| +| strfry + noteguard (8/min) | 8 posts/min | Low | **High** | +| Paid relays | Usually higher | Low | Medium | +| Personal relays | Often unlimited | Low | Low | + +## Mitigation Strategies + +### 1. Staggered Publishing (Recommended) +Add delay between chunk publications: +``` +delay_ms = 60_000 / posts_per_minute_limit +# For 8/min limit: 7.5 seconds between chunks +``` + +**Pros**: Simple, predictable +**Cons**: Slow for many chunks + +### 2. Multi-Relay Distribution +Publish different chunks to different relays: +``` +chunk[0] -> relay A +chunk[1] -> relay B +chunk[2] -> relay C +``` + +**Pros**: Parallelism, faster +**Cons**: Requires cross-relay aggregation (already implemented) + +### 3. Batch with Backoff +Send initial batch, then exponential backoff on rate limit: +```rust +for chunk in chunks { + match publish(chunk).await { + Ok(_) => continue, + Err(RateLimited) => { + delay(backoff_ms).await; + backoff_ms *= 2; + } + } +} +``` + +**Pros**: Adapts to relay limits +**Cons**: Complex error detection + +### 4. Relay Hint Tags +Include relay hints in manifest for chunk locations: +```json +{ + "chunk_ids": ["abc123", "def456"], + "chunk_relays": { + "abc123": ["wss://relay1.example"], + "def456": ["wss://relay2.example"] + } +} +``` + +**Pros**: Enables targeted fetching +**Cons**: Protocol extension needed + +## Recommendations + +### For SDK Senders + +1. **Default behavior**: Publish chunks to all relays with 100ms delay between chunks +2. **Configuration option**: Allow customizing `chunk_publish_delay_ms` +3. **Parallel relay publishing**: Continue publishing same chunk to multiple relays simultaneously +4. **Sequential chunk publishing**: Publish chunks one at a time (with delay) to avoid bursts + +### For Receiver + +1. **Already implemented**: Cross-relay aggregation handles chunks distributed across relays +2. **Timeout handling**: Current 30-second timeout per relay is adequate +3. **Retry logic**: Consider adding retry for individual missing chunks + +### Suggested Default Values + +```rust +// 7500ms between posts to same relay (strfry+noteguard: 8 posts/min = 7500ms) +const DEFAULT_CHUNK_PUBLISH_DELAY_MS: u64 = 7500; +const DEFAULT_RELAY_CONNECT_TIMEOUT_MS: u64 = 10_000; +const DEFAULT_CHUNK_FETCH_TIMEOUT_MS: u64 = 30_000; +``` + +Note: With round-robin distribution across N relays, effective throughput is +N × (1000 / 7500) = N × 0.13 chunks/second per relay, or ~8 chunks/minute total +when using 4 relays. + +## Testing Plan + +1. Create test with 5 chunks (250KB payload) +2. Publish to damus.io relay +3. Measure success rate with different delays: + - 0ms (burst) + - 100ms + - 500ms + - 1000ms +4. Document minimum safe delay for common relays + +## Sources + +- [noteguard - damus strfry plugin](https://github.com/damus-io/noteguard) +- [strfry relay](https://github.com/hoytech/strfry) diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index 581d7f1..ab91943 100644 --- a/rust/src/bin/main.rs +++ b/rust/src/bin/main.rs @@ -6,6 +6,8 @@ use bugstr::{ decompress_payload, parse_crash_content, AppState, CrashReport, CrashStorage, create_router, MappingStore, Platform, Symbolicator, SymbolicationContext, + is_crash_report_kind, is_chunked_kind, DirectPayload, ManifestPayload, ChunkPayload, + reassemble_payload, KIND_CHUNK, }; use tokio::sync::Mutex; use chrono::{DateTime, Utc}; @@ -474,15 +476,19 @@ async fn serve( // Channel for received crashes let (tx, mut rx) = mpsc::channel::(100); + // Clone relay list for chunk fetching (need all relays available to each listener) + let all_relays: Vec = relays.iter().cloned().collect(); + // Spawn relay listeners for relay_url in relays { let relay = relay_url.clone(); let keys = keys.clone(); let tx = tx.clone(); + let relay_urls = all_relays.clone(); tokio::spawn(async move { loop { - match subscribe_relay_with_storage(&relay, &keys, &tx).await { + match subscribe_relay_with_storage(&relay, &keys, &tx, &relay_urls).await { Ok(()) => {} Err(e) => { let err_msg = e.to_string(); @@ -553,6 +559,7 @@ async fn subscribe_relay_with_storage( relay_url: &str, keys: &Keys, tx: &mpsc::Sender, + all_relay_urls: &[String], ) -> Result<(), Box> { let mut seen: HashSet = HashSet::new(); let (ws_stream, _) = connect_async(relay_url).await?; @@ -577,7 +584,7 @@ async fn subscribe_relay_with_storage( while let Some(msg) = read.next().await { match msg { Ok(Message::Text(text)) => { - if let Some(crash) = handle_message_for_storage(&text, keys, &mut seen) { + if let Some(crash) = handle_message_for_storage(&text, keys, &mut seen, all_relay_urls).await { if tx.send(crash).await.is_err() { break; } @@ -598,11 +605,271 @@ async fn subscribe_relay_with_storage( Ok(()) } +/// Fetch chunk events from relays by their event IDs. +/// +/// Uses relay hints from the manifest when available to optimize fetching. +/// For each chunk, tries the hinted relay first before falling back to all relays. +/// +/// # Arguments +/// +/// * `relay_urls` - List of relay WebSocket URLs to query (fallback) +/// * `chunk_ids` - Event IDs of chunks to fetch (hex-encoded) +/// * `chunk_relays` - Optional map of chunk ID to relay hints from manifest +/// +/// # Returns +/// +/// Vector of `ChunkPayload` in order by index, ready for reassembly. +/// +/// # Errors +/// +/// Returns an error if: +/// - Any chunk ID is not valid hex +/// - Not all chunks could be fetched from all relays combined +/// - A chunk is missing at a specific index +async fn fetch_chunks( + relay_urls: &[String], + chunk_ids: &[String], + chunk_relays: Option<&std::collections::HashMap>>, +) -> Result, Box> { + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::Mutex as TokioMutex; + + if chunk_ids.is_empty() { + return Ok(vec![]); + } + + // Parse event IDs + let event_ids: Vec = chunk_ids + .iter() + .filter_map(|id| EventId::from_hex(id).ok()) + .collect(); + + if event_ids.len() != chunk_ids.len() { + return Err("Invalid chunk event IDs in manifest".into()); + } + + let expected_count = chunk_ids.len(); + let chunks: Arc>> = Arc::new(TokioMutex::new(HashMap::new())); + + // Determine if we have relay hints + let has_hints = chunk_relays.map(|h| !h.is_empty()).unwrap_or(false); + + if has_hints { + println!(" {} Fetching {} chunks using relay hints", "↓".blue(), expected_count); + + // Phase 1: Try hinted relays first (grouped by relay for efficiency) + let mut relay_to_chunks: HashMap> = HashMap::new(); + + for (i, chunk_id) in chunk_ids.iter().enumerate() { + if let Some(hints) = chunk_relays.and_then(|h| h.get(chunk_id)) { + if let Some(relay) = hints.first() { + relay_to_chunks + .entry(relay.clone()) + .or_default() + .push((i, event_ids[i])); + } + } + } + + // Spawn parallel fetch tasks for hinted relays + let mut handles = Vec::new(); + for (relay_url, chunk_indices) in relay_to_chunks { + let relay = relay_url.clone(); + let ids: Vec = chunk_indices.iter().map(|(_, id)| *id).collect(); + let chunks_clone = Arc::clone(&chunks); + let expected = expected_count; + + let handle = tokio::spawn(async move { + fetch_chunks_from_relay(&relay, &ids, chunks_clone, expected).await + }); + handles.push(handle); + } + + // Wait for hinted relay fetches + for handle in handles { + let _ = handle.await; + } + + // Check if we got all chunks from hinted relays + let current_count = chunks.lock().await.len(); + if current_count == expected_count { + println!(" {} All {} chunks retrieved from hinted relays", "✓".green(), expected_count); + let final_chunks = chunks.lock().await; + let mut ordered: Vec = Vec::with_capacity(expected_count); + for i in 0..expected_count { + match final_chunks.get(&(i as u32)) { + Some(chunk) => ordered.push(chunk.clone()), + None => return Err(format!("Missing chunk at index {}", i).into()), + } + } + return Ok(ordered); + } + + // Phase 2: Fall back to all relays for missing chunks + let missing = expected_count - current_count; + println!(" {} {} chunks missing, falling back to all relays", "↓".blue(), missing); + } else { + println!(" {} Fetching {} chunks from {} relays in parallel", "↓".blue(), expected_count, relay_urls.len()); + } + + // Spawn parallel fetch tasks for all relays (for missing chunks or no hints) + let mut handles = Vec::new(); + for relay_url in relay_urls { + let relay = relay_url.clone(); + let ids = event_ids.clone(); + let chunks_clone = Arc::clone(&chunks); + let expected = expected_count; + + let handle = tokio::spawn(async move { + fetch_chunks_from_relay(&relay, &ids, chunks_clone, expected).await + }); + handles.push(handle); + } + + // Wait for all relay fetches to complete + for handle in handles { + let _ = handle.await; + } + + // Extract results + let final_chunks = chunks.lock().await; + + // Check we got all chunks + if final_chunks.len() != expected_count { + return Err(format!( + "Missing chunks: got {}, expected {} (aggregated across {} relays)", + final_chunks.len(), + expected_count, + relay_urls.len() + ).into()); + } + + // Return chunks in order + let mut ordered: Vec = Vec::with_capacity(expected_count); + for i in 0..expected_count { + match final_chunks.get(&(i as u32)) { + Some(chunk) => ordered.push(chunk.clone()), + None => return Err(format!("Missing chunk at index {}", i).into()), + } + } + + println!(" {} All {} chunks retrieved", "✓".green(), expected_count); + Ok(ordered) +} + +/// Fetch chunks from a single relay into the shared chunks map. +async fn fetch_chunks_from_relay( + relay_url: &str, + event_ids: &[EventId], + chunks: Arc>>, + expected_count: usize, +) { + use tokio::time::{timeout, Duration}; + + let connect_result = timeout(Duration::from_secs(10), connect_async(relay_url)).await; + let (ws_stream, _) = match connect_result { + Ok(Ok(stream)) => stream, + Ok(Err(e)) => { + eprintln!(" {} {}: connect failed: {}", "⚠".yellow(), relay_url, e); + return; + } + Err(_) => { + eprintln!(" {} {}: connect timeout", "⚠".yellow(), relay_url); + return; + } + }; + + let (mut write, mut read) = ws_stream.split(); + + // Check which chunks we still need + let needed: Vec = { + let current = chunks.lock().await; + event_ids + .iter() + .enumerate() + .filter(|(i, _)| !current.contains_key(&(*i as u32))) + .map(|(_, id)| *id) + .collect() + }; + + if needed.is_empty() { + return; + } + + let filter = Filter::new() + .ids(needed) + .kind(Kind::Custom(KIND_CHUNK)); + + // Safely extract relay identifier, handling both wss:// and ws:// schemes + let relay_suffix = relay_url + .strip_prefix("wss://") + .or_else(|| relay_url.strip_prefix("ws://")) + .unwrap_or(relay_url); + let subscription_id = format!("bugstr-{}", relay_suffix.chars().take(8).collect::()); + let req = format!( + r#"["REQ","{}",{}]"#, + subscription_id, + serde_json::to_string(&filter).unwrap_or_default() + ); + + if write.send(Message::Text(req.into())).await.is_err() { + return; + } + + // Read events with timeout + let fetch_timeout = Duration::from_secs(30); + let start = std::time::Instant::now(); + + while start.elapsed() < fetch_timeout { + // Check if we have all chunks (another relay might have found them) + if chunks.lock().await.len() >= expected_count { + break; + } + + let msg_result = timeout(Duration::from_secs(5), read.next()).await; + + match msg_result { + Ok(Some(Ok(Message::Text(text)))) => { + let msg: Vec = match serde_json::from_str(&text) { + Ok(m) => m, + Err(_) => continue, + }; + + if msg.len() >= 3 && msg[0].as_str() == Some("EVENT") { + if let Ok(event) = serde_json::from_value::(msg[2].clone()) { + if let Ok(chunk) = ChunkPayload::from_json(&event.content) { + let index = chunk.index; + let mut current = chunks.lock().await; + if !current.contains_key(&index) { + current.insert(index, chunk); + println!(" {} {} chunk {}/{}", "✓".green(), relay_url, current.len(), expected_count); + } + } + } + } else if msg.len() >= 2 && msg[0].as_str() == Some("EOSE") { + break; + } + } + Ok(Some(Ok(Message::Close(_)))) => break, + Ok(Some(Ok(_))) => continue, + Ok(Some(Err(_))) => break, + Ok(None) => break, + Err(_) => break, + } + } + + // Close subscription + let close_msg = format!(r#"["CLOSE","{}"]"#, subscription_id); + let _ = write.send(Message::Text(close_msg.into())).await; +} + /// Handle incoming message and return crash for storage. -fn handle_message_for_storage( +async fn handle_message_for_storage( text: &str, keys: &Keys, seen: &mut HashSet, + relay_urls: &[String], ) -> Option { let msg: Vec = serde_json::from_str(text).ok()?; @@ -639,8 +906,83 @@ fn handle_message_for_storage( } }; - // Decompress if needed - let content = decompress_payload(&rumor.content).unwrap_or_else(|_| rumor.content.clone()); + let rumor_kind = rumor.kind as u16; + + // Decompress payload once before any parsing (handles both compressed manifests and direct payloads) + let decompressed = decompress_payload(&rumor.content).unwrap_or_else(|_| rumor.content.clone()); + + // Handle different transport kinds + if is_chunked_kind(rumor_kind) { + // Kind 10421: Manifest for chunked crash report + match ManifestPayload::from_json(&decompressed) { + Ok(manifest) => { + println!( + "{} Received manifest: {} chunks, {} bytes total", + "📦".cyan(), + manifest.chunk_count, + manifest.total_size + ); + + // Fetch chunks from relays (using relay hints if available) + let chunks = match fetch_chunks( + relay_urls, + &manifest.chunk_ids, + manifest.chunk_relays.as_ref(), + ).await { + Ok(c) => c, + Err(e) => { + eprintln!("{} Failed to fetch chunks: {}", "✗".red(), e); + return None; + } + }; + + // Reassemble the payload + let reassembled = match reassemble_payload(&manifest, &chunks) { + Ok(data) => data, + Err(e) => { + eprintln!("{} Failed to reassemble payload: {}", "✗".red(), e); + return None; + } + }; + + // Decompress reassembled data + let payload_str = String::from_utf8_lossy(&reassembled); + let decompressed = decompress_payload(&payload_str) + .unwrap_or_else(|_| payload_str.to_string()); + + println!("{} Reassembled {} bytes from {} chunks", "✓".green(), decompressed.len(), chunks.len()); + + return Some(ReceivedCrash { + event_id: event.id.to_hex(), + sender_pubkey: rumor.pubkey.clone(), + created_at: rumor.created_at as i64, + content: decompressed, + }); + } + Err(e) => { + eprintln!("{} Failed to parse manifest: {}", "✗".red(), e); + return None; + } + } + } + + // Extract crash content based on transport kind (decompressed already computed above) + let content = if is_crash_report_kind(rumor_kind) { + // Kind 10420: Direct crash report with DirectPayload wrapper + match DirectPayload::from_json(&decompressed) { + Ok(direct) => { + // Convert JSON value to string for storage + serde_json::to_string(&direct.crash).unwrap_or(decompressed) + } + Err(_) => { + // Fall back to treating content as raw crash data + decompressed + } + } + } else { + // Legacy kind 14 or other: treat content as raw crash data + decompressed + }; Some(ReceivedCrash { event_id: event.id.to_hex(), @@ -799,14 +1141,44 @@ struct Rumor { } fn unwrap_gift_wrap(keys: &Keys, gift_wrap: &Event) -> Result> { + // Verify gift wrap signature (NIP-59: gift wrap is signed by random keypair) + gift_wrap.verify()?; + // Decrypt gift wrap to get seal let seal_json = nip44::decrypt(keys.secret_key(), &gift_wrap.pubkey, &gift_wrap.content)?; let seal: Event = serde_json::from_str(&seal_json)?; + // Verify seal kind (NIP-59: seal MUST be kind 13) + if seal.kind != Kind::Seal { + return Err(format!("Invalid seal kind: expected 13, got {}", seal.kind.as_u16()).into()); + } + + // Verify seal tags are empty (NIP-59: seal tags MUST be empty) + if !seal.tags.is_empty() { + return Err("Invalid seal: tags must be empty".into()); + } + + // Verify seal signature (NIP-59: seal is signed by sender) + seal.verify()?; + // Decrypt seal to get rumor (unsigned, so parse as Rumor not Event) let rumor_json = nip44::decrypt(keys.secret_key(), &seal.pubkey, &seal.content)?; let rumor: Rumor = serde_json::from_str(&rumor_json)?; + // Verify rumor sig is empty (NIP-59: rumors are unsigned) + if !rumor.sig.is_empty() { + return Err("Invalid rumor: sig must be empty".into()); + } + + // Verify seal.pubkey matches rumor.pubkey (NIP-17: prevent sender spoofing) + if seal.pubkey.to_hex() != rumor.pubkey { + return Err(format!( + "Sender spoofing detected: seal.pubkey ({}) != rumor.pubkey ({})", + &seal.pubkey.to_hex()[..16], + &rumor.pubkey[..16] + ).into()); + } + Ok(rumor) } diff --git a/rust/src/chunking.rs b/rust/src/chunking.rs new file mode 100644 index 0000000..9c234f3 --- /dev/null +++ b/rust/src/chunking.rs @@ -0,0 +1,377 @@ +//! CHK-based chunking for large crash reports. +//! +//! Implements Content Hash Key (CHK) encryption and chunking for crash reports +//! that exceed the direct transport size limit (50KB). Large payloads are split +//! into chunks, each encrypted with its content hash as the key. +//! +//! # Security Model +//! +//! CHK encryption ensures that: +//! - Each chunk is encrypted with a key derived from its plaintext hash +//! - The root hash (manifest's `root_hash`) is required to decrypt any chunk +//! - Without the manifest (delivered via NIP-17 gift wrap), chunks are opaque +//! +//! # Chunk Size +//! +//! Chunks are sized to fit within Nostr relay limits: +//! - Max event size: 64KB (strfry default) +//! - Chunk payload: 48KB (allows for base64 encoding + JSON overhead) +//! +//! # Example +//! +//! ```ignore +//! use bugstr::chunking::{chunk_payload, reassemble_payload}; +//! +//! // Chunking (sender side) +//! let large_payload = vec![0u8; 100_000]; // 100KB +//! let result = chunk_payload(&large_payload)?; +//! // result.manifest contains root_hash and chunk metadata +//! // result.chunks contains encrypted chunk data +//! +//! // Reassembly (receiver side) +//! let original = reassemble_payload(&result.manifest, &result.chunks)?; +//! assert_eq!(original, large_payload); +//! ``` + +use hashtree_core::crypto::{decrypt_chk, encrypt_chk, EncryptionKey}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::transport::{ChunkPayload, ManifestPayload, MAX_CHUNK_SIZE}; + +/// Errors that can occur during chunking operations. +#[derive(Debug, Error)] +pub enum ChunkingError { + #[error("Payload too small for chunking (use direct transport)")] + PayloadTooSmall, + + #[error("Encryption failed: {0}")] + EncryptionError(String), + + #[error("Decryption failed: {0}")] + DecryptionError(String), + + #[error("Invalid manifest: {0}")] + InvalidManifest(String), + + #[error("Missing chunk at index {0}")] + MissingChunk(u32), + + #[error("Chunk hash mismatch at index {0}")] + ChunkHashMismatch(u32), + + #[error("Invalid root hash")] + InvalidRootHash, +} + +/// Result of chunking a large payload. +#[derive(Debug, Clone)] +pub struct ChunkingResult { + /// Manifest containing root hash and chunk metadata. + pub manifest: ManifestPayload, + + /// Encrypted chunks ready for publishing. + pub chunks: Vec, +} + +/// Chunk a large payload using CHK encryption. +/// +/// Splits the payload into chunks, encrypts each with its content hash, +/// and computes a root hash for the manifest. +/// +/// # Arguments +/// +/// * `data` - The payload bytes to chunk (must be >50KB, use direct transport for smaller payloads) +/// +/// # Returns +/// +/// A `ChunkingResult` containing the manifest and encrypted chunks. +/// +/// # Errors +/// +/// * `ChunkingError::PayloadTooSmall` if data is ≤50KB (use direct transport instead) +/// * `ChunkingError::EncryptionError` if CHK encryption fails +pub fn chunk_payload(data: &[u8]) -> Result { + use base64::Engine; + use crate::transport::DIRECT_SIZE_THRESHOLD; + + // Enforce minimum size - payloads ≤50KB should use direct transport + if data.len() <= DIRECT_SIZE_THRESHOLD { + return Err(ChunkingError::PayloadTooSmall); + } + + let total_size = data.len() as u64; + let chunk_size = MAX_CHUNK_SIZE; + + // Split data into chunks + let mut chunks: Vec = Vec::new(); + let mut chunk_keys: Vec = Vec::new(); + + for (index, chunk_data) in data.chunks(chunk_size).enumerate() { + // Encrypt chunk using CHK - returns (ciphertext, key) where key = SHA256(plaintext) + let (ciphertext, key) = encrypt_chk(chunk_data) + .map_err(|e| ChunkingError::EncryptionError(e.to_string()))?; + + // The key IS the content hash (CHK property) + let chunk_hash_hex = hex::encode(&key); + + // Base64 encode ciphertext for JSON transport + let encoded_data = base64::engine::general_purpose::STANDARD.encode(&ciphertext); + + chunks.push(ChunkPayload { + v: 1, + index: index as u32, + hash: chunk_hash_hex, + data: encoded_data, + }); + + chunk_keys.push(key); + } + + // Compute root hash from all chunk keys (simple concatenation + hash) + let mut root_hasher = Sha256::new(); + for key in &chunk_keys { + root_hasher.update(key); + } + let root_hash = hex::encode(root_hasher.finalize()); + + // Build manifest (chunk_ids and chunk_relays will be filled after publishing) + let manifest = ManifestPayload { + v: 1, + root_hash, + total_size, + chunk_count: chunks.len() as u32, + chunk_ids: vec![], // To be filled by caller after publishing chunks + chunk_relays: None, // Optional relay hints, filled by sender + }; + + Ok(ChunkingResult { manifest, chunks }) +} + +/// Reassemble a chunked payload from manifest and chunks. +/// +/// Verifies chunk hashes, decrypts using CHK, and reconstructs the original payload. +/// +/// # Arguments +/// +/// * `manifest` - The manifest containing root hash and chunk metadata +/// * `chunks` - The encrypted chunks (must be in order by index) +/// +/// # Returns +/// +/// The original decrypted payload bytes. +/// +/// # Errors +/// +/// - `ChunkingError::MissingChunk` if a chunk is missing +/// - `ChunkingError::ChunkHashMismatch` if a chunk's hash doesn't match +/// - `ChunkingError::DecryptionError` if CHK decryption fails +/// - `ChunkingError::InvalidRootHash` if the root hash doesn't verify +pub fn reassemble_payload( + manifest: &ManifestPayload, + chunks: &[ChunkPayload], +) -> Result, ChunkingError> { + use base64::Engine; + + // Verify chunk count + if chunks.len() != manifest.chunk_count as usize { + return Err(ChunkingError::InvalidManifest(format!( + "Expected {} chunks, got {}", + manifest.chunk_count, + chunks.len() + ))); + } + + // Sort chunks by index + let mut sorted_chunks = chunks.to_vec(); + sorted_chunks.sort_by_key(|c| c.index); + + // Verify all indices are present + for (i, chunk) in sorted_chunks.iter().enumerate() { + if chunk.index != i as u32 { + return Err(ChunkingError::MissingChunk(i as u32)); + } + } + + // Decrypt and reassemble + let mut result = Vec::with_capacity(manifest.total_size as usize); + let mut chunk_keys: Vec = Vec::new(); + + for chunk in &sorted_chunks { + // Decode the chunk hash to get the decryption key + let key_bytes = hex::decode(&chunk.hash) + .map_err(|e| ChunkingError::DecryptionError(format!("Invalid chunk hash: {}", e)))?; + + let key: EncryptionKey = key_bytes + .try_into() + .map_err(|_| ChunkingError::DecryptionError("Invalid key length".to_string()))?; + + // Decode base64 ciphertext + let ciphertext = base64::engine::general_purpose::STANDARD + .decode(&chunk.data) + .map_err(|e| ChunkingError::DecryptionError(format!("Base64 decode failed: {}", e)))?; + + // Decrypt using CHK with the stored key + let decrypted = decrypt_chk(&ciphertext, &key) + .map_err(|e| ChunkingError::DecryptionError(e.to_string()))?; + + // Verify the decryption by re-encrypting and checking the key matches + // (This is implicit in CHK - if decryption succeeds with the key, it's valid) + + chunk_keys.push(key); + result.extend_from_slice(&decrypted); + } + + // Verify root hash + let mut root_hasher = Sha256::new(); + for key in &chunk_keys { + root_hasher.update(key); + } + let computed_root = hex::encode(root_hasher.finalize()); + if computed_root != manifest.root_hash { + return Err(ChunkingError::InvalidRootHash); + } + + Ok(result) +} + +/// Compute the expected number of chunks for a given payload size. +pub fn expected_chunk_count(payload_size: usize) -> u32 { + let chunk_size = MAX_CHUNK_SIZE; + ((payload_size + chunk_size - 1) / chunk_size) as u32 +} + +/// Estimate the total overhead for chunking a payload. +/// +/// Returns approximate overhead in bytes from: +/// - Base64 encoding (~33% increase) +/// - CHK encryption (~16 bytes per chunk) +/// - JSON metadata +pub fn estimate_overhead(payload_size: usize) -> usize { + let num_chunks = expected_chunk_count(payload_size) as usize; + // Base64 overhead: 4/3 ratio + // CHK overhead: ~16 bytes nonce per chunk + // JSON overhead: ~100 bytes per chunk for metadata + let base64_overhead = payload_size / 3; + let chk_overhead = num_chunks * 16; + let json_overhead = num_chunks * 100; + base64_overhead + chk_overhead + json_overhead +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Generate test vector for cross-SDK CHK compatibility verification. + /// Run with: cargo test generate_chk_test_vector -- --nocapture + #[test] + fn generate_chk_test_vector() { + use base64::Engine; + + // Use a simple, reproducible plaintext + let plaintext = b"Hello, CHK test vector!"; + + // Encrypt using hashtree-core (the reference implementation) + let (ciphertext, key) = encrypt_chk(plaintext).expect("encryption should succeed"); + + // The key IS the content hash in CHK + let content_hash = key; + + // Print test vector in JSON format + println!("\n=== CHK Test Vector ==="); + println!("{{"); + println!(" \"plaintext\": \"{}\",", String::from_utf8_lossy(plaintext)); + println!( + " \"plaintext_hex\": \"{}\",", + hex::encode(plaintext) + ); + println!(" \"content_hash\": \"{}\",", hex::encode(&content_hash)); + println!( + " \"ciphertext_base64\": \"{}\",", + base64::engine::general_purpose::STANDARD.encode(&ciphertext) + ); + println!(" \"ciphertext_hex\": \"{}\",", hex::encode(&ciphertext)); + println!(" \"ciphertext_length\": {}", ciphertext.len()); + println!("}}"); + + // Verify round-trip + let decrypted = decrypt_chk(&ciphertext, &content_hash).expect("decryption should succeed"); + assert_eq!(decrypted, plaintext); + + println!("\n=== Round-trip verified ===\n"); + } + + #[test] + fn test_payload_too_small_error() { + // Payloads ≤50KB should return PayloadTooSmall error + let small_data = vec![42u8; 1000]; + let result = chunk_payload(&small_data); + assert!(matches!(result, Err(ChunkingError::PayloadTooSmall))); + + // Exactly at threshold should also error + let threshold_data = vec![42u8; 50 * 1024]; + let result = chunk_payload(&threshold_data); + assert!(matches!(result, Err(ChunkingError::PayloadTooSmall))); + } + + #[test] + fn test_chunk_and_reassemble_minimum() { + // Just over DIRECT_SIZE_THRESHOLD (50KB) - produces 2 chunks because MAX_CHUNK_SIZE is 48KB + // 50KB+1 = 51201 bytes → chunk 0: 48KB, chunk 1: ~3KB + let data = vec![42u8; 50 * 1024 + 1]; + let result = chunk_payload(&data).unwrap(); + + assert_eq!(result.chunks.len(), 2); + assert_eq!(result.manifest.chunk_count, 2); + assert_eq!(result.manifest.total_size, 50 * 1024 + 1); + + let reassembled = reassemble_payload(&result.manifest, &result.chunks).unwrap(); + assert_eq!(reassembled, data); + } + + #[test] + fn test_chunk_and_reassemble_large() { + // Large payload spanning multiple chunks + let data: Vec = (0..150_000).map(|i| (i % 256) as u8).collect(); + let result = chunk_payload(&data).unwrap(); + + assert!(result.chunks.len() > 1); + assert_eq!(result.manifest.total_size, 150_000); + + let reassembled = reassemble_payload(&result.manifest, &result.chunks).unwrap(); + assert_eq!(reassembled, data); + } + + #[test] + fn test_root_hash_deterministic() { + // Payload must be >50KB + let data: Vec = (0..60_000).map(|i| (i % 256) as u8).collect(); + let result1 = chunk_payload(&data).unwrap(); + let result2 = chunk_payload(&data).unwrap(); + + assert_eq!(result1.manifest.root_hash, result2.manifest.root_hash); + } + + #[test] + fn test_chunk_hash_verification() { + // Payload must be >50KB + let data: Vec = (0..60_000).map(|i| (i % 256) as u8).collect(); + let mut result = chunk_payload(&data).unwrap(); + + // Corrupt chunk hash (which is the decryption key) + // This should cause decryption to fail + result.chunks[0].hash = "0000000000000000000000000000000000000000000000000000000000000000".to_string(); + + let err = reassemble_payload(&result.manifest, &result.chunks).unwrap_err(); + // With a wrong key, decryption will fail + assert!(matches!(err, ChunkingError::DecryptionError(_))); + } + + #[test] + fn test_expected_chunk_count() { + assert_eq!(expected_chunk_count(1000), 1); + assert_eq!(expected_chunk_count(MAX_CHUNK_SIZE), 1); + assert_eq!(expected_chunk_count(MAX_CHUNK_SIZE + 1), 2); + assert_eq!(expected_chunk_count(MAX_CHUNK_SIZE * 3), 3); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index c5c0e84..9e47994 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -23,12 +23,18 @@ //! } //! ``` +pub mod chunking; pub mod compression; pub mod event; pub mod storage; pub mod symbolication; +pub mod transport; pub mod web; +pub use chunking::{ + chunk_payload, reassemble_payload, expected_chunk_count, estimate_overhead, + ChunkingError, ChunkingResult, +}; pub use compression::{compress_payload, decompress_payload, maybe_compress_payload, DEFAULT_THRESHOLD}; pub use event::UnsignedNostrEvent; pub use storage::{CrashReport, CrashGroup, CrashStorage, parse_crash_content}; @@ -36,6 +42,11 @@ pub use symbolication::{ MappingStore, Platform, Symbolicator, SymbolicatedFrame, SymbolicatedStack, SymbolicationContext, SymbolicationError, }; +pub use transport::{ + DirectPayload, ManifestPayload, ChunkPayload, TransportKind, + KIND_DIRECT, KIND_MANIFEST, KIND_CHUNK, DIRECT_SIZE_THRESHOLD, + is_crash_report_kind, is_chunked_kind, +}; pub use web::{create_router, AppState}; /// Configuration for the crash report handler. diff --git a/rust/src/transport.rs b/rust/src/transport.rs new file mode 100644 index 0000000..bb5fe59 --- /dev/null +++ b/rust/src/transport.rs @@ -0,0 +1,417 @@ +//! Transport layer for crash report delivery. +//! +//! Defines event kinds and payload structures for delivering crash reports +//! via Nostr. Supports both direct delivery (≤50KB) and hashtree-based +//! chunked delivery (>50KB) for large crash reports. +//! +//! # Event Kinds +//! +//! | Kind | Name | Description | +//! |------|------|-------------| +//! | 10420 | Direct | Small crash report delivered directly in event content | +//! | 10421 | Manifest | Hashtree manifest with root hash and chunk metadata | +//! | 10422 | Chunk | CHK-encrypted chunk data (public, content-addressed) | +//! +//! # Transport Selection +//! +//! ```text +//! payload_size ≤ 50KB → kind 10420 (direct) +//! payload_size > 50KB → kind 10421 manifest + kind 10422 chunks +//! ``` +//! +//! # Security Model +//! +//! - **Direct (10420)**: Gift-wrapped via NIP-17 for end-to-end encryption +//! - **Manifest (10421)**: Gift-wrapped via NIP-17; contains root hash (decryption key) +//! - **Chunks (10422)**: Public events with CHK encryption; root hash required to decrypt +//! +//! The root hash serves as the Content Hash Key (CHK) - without the manifest, +//! chunks are opaque encrypted blobs that cannot be decrypted. + +use serde::{Deserialize, Serialize}; + +/// Event kind for direct crash report delivery (≤50KB). +/// +/// Used when the compressed crash report fits within relay message limits. +/// The event is gift-wrapped via NIP-17 for end-to-end encryption. +pub const KIND_DIRECT: u16 = 10420; + +/// Event kind for hashtree manifest (>50KB crash reports). +/// +/// Contains the root hash (decryption key) and chunk metadata needed +/// to reconstruct and decrypt a large crash report. Gift-wrapped via NIP-17. +pub const KIND_MANIFEST: u16 = 10421; + +/// Event kind for CHK-encrypted chunk data. +/// +/// Public events containing encrypted chunk data. Cannot be decrypted +/// without the root hash from the corresponding manifest. +pub const KIND_CHUNK: u16 = 10422; + +/// Size threshold for switching from direct to chunked transport. +/// +/// Crash reports ≤50KB use direct transport (kind 10420). +/// Crash reports >50KB use chunked transport (kind 10421 + 10422). +/// +/// This threshold accounts for: +/// - 64KB relay message limit (strfry default) +/// - Gift wrap overhead (~14KB for NIP-17 envelope) +pub const DIRECT_SIZE_THRESHOLD: usize = 50 * 1024; // 50KB + +/// Maximum chunk size for hashtree transport. +/// +/// Each chunk must fit within the 64KB relay limit after base64 encoding +/// and event envelope overhead. +pub const MAX_CHUNK_SIZE: usize = 48 * 1024; // 48KB + +/// Direct crash report payload (kind 10420). +/// +/// Used for crash reports that fit within the direct transport threshold. +/// The payload is JSON-serialized and placed in the event content field. +/// +/// # Fields +/// +/// * `v` - Protocol version (currently 1) for forward compatibility +/// * `crash` - JSON object containing crash data (message, stack, timestamp, etc.) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DirectPayload { + /// Protocol version for forward compatibility. + pub v: u8, + + /// Crash report data (JSON object). + /// + /// Contains fields like: message, stack, timestamp, environment, release, platform + pub crash: serde_json::Value, +} + +impl DirectPayload { + /// Creates a new direct payload with the given crash data. + /// + /// # Parameters + /// + /// * `crash` - A `serde_json::Value` containing the crash report data. + /// Typically a JSON object with fields like `message`, `stack`, `timestamp`, etc. + /// + /// # Returns + /// + /// A new `DirectPayload` with protocol version 1. + pub fn new(crash: serde_json::Value) -> Self { + Self { v: 1, crash } + } + + /// Serializes the payload to a JSON string. + /// + /// # Returns + /// + /// * `Ok(String)` - The JSON-serialized payload + /// + /// # Errors + /// + /// Returns `serde_json::Error` if serialization fails. This can occur if + /// the `crash` field contains values that cannot be serialized (e.g., NaN floats). + pub fn to_json(&self) -> Result { + serde_json::to_string(self) + } + + /// Deserializes a payload from a JSON string. + /// + /// # Parameters + /// + /// * `json` - A JSON string representing a `DirectPayload` + /// + /// # Returns + /// + /// * `Ok(DirectPayload)` - The deserialized payload + /// + /// # Errors + /// + /// Returns `serde_json::Error` if: + /// * The JSON is malformed or invalid + /// * Required fields (`v`, `crash`) are missing + /// * Field types don't match expected types + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } +} + +/// Hashtree manifest payload (kind 10421). +/// +/// Contains metadata needed to fetch and decrypt a chunked crash report. +/// The root_hash serves as the CHK (Content Hash Key) for decryption. +/// +/// # Fields +/// +/// * `v` - Protocol version (currently 1) for forward compatibility +/// * `root_hash` - Hex-encoded root hash (the CHK decryption key) +/// * `total_size` - Original uncompressed payload size in bytes +/// * `chunk_count` - Number of chunks to fetch +/// * `chunk_ids` - Ordered list of chunk event IDs (kind 10422) +/// * `chunk_relays` - Optional relay hints mapping chunk IDs to relay URLs +/// +/// # Security +/// +/// The manifest is delivered via NIP-17 gift wrap, keeping the `root_hash` +/// secret. Without the root hash, chunks cannot be decrypted. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestPayload { + /// Protocol version for forward compatibility. + pub v: u8, + + /// Root hash of the hashtree (hex-encoded). + /// + /// This is the CHK - the key needed to decrypt the chunks. + /// Keeping this secret (via NIP-17 gift wrap) ensures only the + /// intended recipient can decrypt the crash report. + pub root_hash: String, + + /// Total size of the original unencrypted crash report in bytes. + pub total_size: u64, + + /// Number of chunks. + pub chunk_count: u32, + + /// Event IDs of the chunk events (kind 10422). + /// + /// Ordered list of chunk event IDs for retrieval. + pub chunk_ids: Vec, + + /// Optional relay hints for each chunk (for optimized fetching). + /// + /// Maps chunk event ID to list of relay URLs where that chunk was published. + /// If present, the receiver should try these relays first when fetching chunks. + #[serde(skip_serializing_if = "Option::is_none")] + pub chunk_relays: Option>>, +} + +impl ManifestPayload { + /// Serializes the manifest to a JSON string. + /// + /// # Returns + /// + /// * `Ok(String)` - The JSON-serialized manifest + /// + /// # Errors + /// + /// Returns `serde_json::Error` if serialization fails. This is unlikely + /// for valid manifest data but can occur with invalid UTF-8 in strings. + pub fn to_json(&self) -> Result { + serde_json::to_string(self) + } + + /// Deserializes a manifest from a JSON string. + /// + /// # Parameters + /// + /// * `json` - A JSON string representing a `ManifestPayload` + /// + /// # Returns + /// + /// * `Ok(ManifestPayload)` - The deserialized manifest + /// + /// # Errors + /// + /// Returns `serde_json::Error` if: + /// * The JSON is malformed or invalid + /// * Required fields (`v`, `root_hash`, `total_size`, `chunk_count`, `chunk_ids`) are missing + /// * Field types don't match (e.g., `total_size` is not a number) + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } +} + +/// Chunk payload (kind 10422). +/// +/// Contains a single CHK-encrypted chunk of crash report data. +/// Published as a public event - CHK encryption prevents unauthorized decryption +/// since the decryption key (root_hash) is only in the private manifest. +/// +/// # Fields +/// +/// * `v` - Protocol version (currently 1) for forward compatibility +/// * `index` - Zero-based chunk index for ordering during reassembly +/// * `hash` - Hex-encoded SHA-256 hash of plaintext (also the CHK decryption key for this chunk) +/// * `data` - Base64-encoded AES-256-GCM ciphertext with appended auth tag +/// +/// # Security +/// +/// Chunks are public but opaque without the manifest's root_hash. The hash field +/// is the CHK for this specific chunk, derived via HKDF from the content hash. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChunkPayload { + /// Protocol version for forward compatibility. + pub v: u8, + + /// Chunk index (0-based). + pub index: u32, + + /// Hash of this chunk (hex-encoded). + /// + /// Used for content addressing and integrity verification. + pub hash: String, + + /// CHK-encrypted chunk data (base64-encoded). + pub data: String, +} + +impl ChunkPayload { + /// Serializes the chunk to a JSON string. + /// + /// # Returns + /// + /// * `Ok(String)` - The JSON-serialized chunk + /// + /// # Errors + /// + /// Returns `serde_json::Error` if serialization fails. This is unlikely + /// for valid chunk data. + pub fn to_json(&self) -> Result { + serde_json::to_string(self) + } + + /// Deserializes a chunk from a JSON string. + /// + /// # Parameters + /// + /// * `json` - A JSON string representing a `ChunkPayload` + /// + /// # Returns + /// + /// * `Ok(ChunkPayload)` - The deserialized chunk + /// + /// # Errors + /// + /// Returns `serde_json::Error` if: + /// * The JSON is malformed or invalid + /// * Required fields (`v`, `index`, `hash`, `data`) are missing + /// * Field types don't match expected types + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } +} + +/// Determines the appropriate transport for a given payload size. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportKind { + /// Direct transport for small payloads (≤50KB). + Direct, + /// Chunked transport for large payloads (>50KB). + Chunked, +} + +impl TransportKind { + /// Determines transport kind based on payload size in bytes. + pub fn for_size(size: usize) -> Self { + if size <= DIRECT_SIZE_THRESHOLD { + Self::Direct + } else { + Self::Chunked + } + } + + /// Returns the event kind number for this transport. + pub fn event_kind(&self) -> u16 { + match self { + Self::Direct => KIND_DIRECT, + Self::Chunked => KIND_MANIFEST, + } + } +} + +/// Checks if an event kind is a recognized bugstr crash report kind. +/// +/// Returns true for: +/// - kind 14 (legacy NIP-17 DM) +/// - kind 10420 (direct crash report) +/// - kind 10421 (manifest) +pub fn is_crash_report_kind(kind: u16) -> bool { + matches!(kind, 14 | KIND_DIRECT | KIND_MANIFEST) +} + +/// Checks if an event kind requires chunked assembly. +pub fn is_chunked_kind(kind: u16) -> bool { + kind == KIND_MANIFEST +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transport_kind_selection() { + // Small payload → direct + assert_eq!(TransportKind::for_size(1024), TransportKind::Direct); + assert_eq!(TransportKind::for_size(50 * 1024), TransportKind::Direct); + + // Large payload → chunked + assert_eq!(TransportKind::for_size(50 * 1024 + 1), TransportKind::Chunked); + assert_eq!(TransportKind::for_size(100 * 1024), TransportKind::Chunked); + } + + #[test] + fn test_direct_payload_serialization() { + let crash = serde_json::json!({ + "message": "Test error", + "stack": "at test.rs:42", + "timestamp": 1234567890 + }); + let payload = DirectPayload::new(crash.clone()); + + let json = payload.to_json().unwrap(); + let parsed = DirectPayload::from_json(&json).unwrap(); + + assert_eq!(parsed.v, 1); + assert_eq!(parsed.crash, crash); + } + + #[test] + fn test_manifest_payload_serialization() { + let manifest = ManifestPayload { + v: 1, + root_hash: "abc123".to_string(), + total_size: 100000, + chunk_count: 3, + chunk_ids: vec!["id1".into(), "id2".into(), "id3".into()], + chunk_relays: None, + }; + + let json = manifest.to_json().unwrap(); + let parsed = ManifestPayload::from_json(&json).unwrap(); + + assert_eq!(parsed.root_hash, "abc123"); + assert_eq!(parsed.chunk_count, 3); + assert!(parsed.chunk_relays.is_none()); + } + + #[test] + fn test_manifest_payload_with_relay_hints() { + use std::collections::HashMap; + let mut chunk_relays = HashMap::new(); + chunk_relays.insert("id1".to_string(), vec!["wss://relay.damus.io".to_string()]); + chunk_relays.insert("id2".to_string(), vec!["wss://nos.lol".to_string()]); + + let manifest = ManifestPayload { + v: 1, + root_hash: "abc123".to_string(), + total_size: 100000, + chunk_count: 2, + chunk_ids: vec!["id1".into(), "id2".into()], + chunk_relays: Some(chunk_relays), + }; + + let json = manifest.to_json().unwrap(); + let parsed = ManifestPayload::from_json(&json).unwrap(); + + assert!(parsed.chunk_relays.is_some()); + let relays = parsed.chunk_relays.unwrap(); + assert_eq!(relays.get("id1").unwrap(), &vec!["wss://relay.damus.io".to_string()]); + } + + #[test] + fn test_is_crash_report_kind() { + assert!(is_crash_report_kind(14)); // Legacy + assert!(is_crash_report_kind(KIND_DIRECT)); + assert!(is_crash_report_kind(KIND_MANIFEST)); + assert!(!is_crash_report_kind(1)); // Regular note + assert!(!is_crash_report_kind(KIND_CHUNK)); // Chunks are not standalone reports + } +} diff --git a/test-vectors/chk-encryption.json b/test-vectors/chk-encryption.json new file mode 100644 index 0000000..dd73863 --- /dev/null +++ b/test-vectors/chk-encryption.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CHK Encryption Test Vectors", + "description": "Test vectors for Content Hash Key (CHK) encryption compatibility across SDKs. Generated from Rust hashtree-core reference implementation.", + "algorithm": { + "name": "CHK (Content Hash Key) Encryption", + "steps": [ + "1. content_hash = SHA256(plaintext)", + "2. derived_key = HKDF-SHA256(ikm=content_hash, salt='hashtree-chk', info='encryption-key', length=32)", + "3. ciphertext = AES-256-GCM(key=derived_key, nonce=12_zero_bytes, plaintext)", + "4. output = ciphertext || auth_tag (16 bytes)" + ], + "constants": { + "hkdf_salt": "hashtree-chk", + "hkdf_info": "encryption-key", + "nonce": "000000000000000000000000", + "nonce_size_bytes": 12, + "auth_tag_size_bytes": 16 + } + }, + "test_vectors": [ + { + "name": "simple_ascii_string", + "description": "Basic ASCII string encryption", + "plaintext": "Hello, CHK test vector!", + "plaintext_hex": "48656c6c6f2c2043484b207465737420766563746f7221", + "content_hash": "01037f41bf5f81f2fb3fd0194a831d1a50ec09e462a28e9139b0e81b2824d3da", + "ciphertext_base64": "yReOAXXWX2ePimAR8zQeYXD9OWLzFhoVM1U7pApbrX1yNUW1pUrF", + "ciphertext_hex": "c9178e0175d65f678f8a6011f3341e6170fd3962f3161a1533553ba40a5bad7d723545b5a54ac5" + } + ], + "verification_steps": [ + "1. Compute SHA256 of plaintext_hex → should equal content_hash", + "2. Derive key using HKDF with content_hash as IKM", + "3. Encrypt plaintext with derived key and zero nonce", + "4. Compare output to ciphertext_hex (must be byte-identical)", + "5. Decrypt ciphertext_hex using content_hash → should equal plaintext" + ], + "references": { + "hashtree_core": "https://crates.io/crates/hashtree-core", + "hkdf_rfc": "https://datatracker.ietf.org/doc/html/rfc5869", + "aes_gcm": "https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf" + } +} diff --git a/test-vectors/validate.js b/test-vectors/validate.js index 36697b2..0f5e40f 100644 --- a/test-vectors/validate.js +++ b/test-vectors/validate.js @@ -1,16 +1,18 @@ /** - * NIP-17 Schema Validation + * NIP-17 Schema Validation + CHK Encryption Verification * - * Validates test vectors against NIP-17 kind-14 schema. - * Schema based on nostrability/schemata specification. + * Validates: + * 1. NIP-17 test vectors against kind-14 schema (nostrability/schemata spec) + * 2. CHK encryption against Rust hashtree-core reference implementation * * Reference: * - https://github.com/nostrability/schemata/blob/master/nips/nip-17/kind-14/schema.yaml * - https://github.com/hzrd149/applesauce/pull/39 + * - https://crates.io/crates/hashtree-core */ import Ajv from "ajv"; -import { createHash } from "crypto"; +import { createHash, hkdfSync, createCipheriv, createDecipheriv } from "crypto"; import { readFileSync } from "fs"; const ajv = new Ajv({ strict: false, allErrors: true }); @@ -178,6 +180,97 @@ for (const testCase of vectors.test_vectors.rumor_json_output) { } } +// Test 3: CHK Encryption Compatibility +console.log("\n3. CHK Encryption Tests (hashtree-core compatibility)\n"); + +const chkVectors = JSON.parse(readFileSync("./chk-encryption.json", "utf-8")); + +/** CHK constants (must match hashtree-core) */ +const CHK_SALT = Buffer.from("hashtree-chk"); +const CHK_INFO = Buffer.from("encryption-key"); +const NONCE_SIZE = 12; +const TAG_SIZE = 16; + +/** + * Derives encryption key from content hash using HKDF-SHA256. + */ +function deriveChkKey(contentHash) { + return Buffer.from(hkdfSync("sha256", contentHash, CHK_SALT, CHK_INFO, 32)); +} + +/** + * Encrypts data using AES-256-GCM with zero nonce. + */ +function chkEncrypt(data, contentHash) { + const key = deriveChkKey(contentHash); + const zeroNonce = Buffer.alloc(NONCE_SIZE); + const cipher = createCipheriv("aes-256-gcm", key, zeroNonce); + const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([ciphertext, authTag]); +} + +/** + * Decrypts data using AES-256-GCM with zero nonce. + */ +function chkDecrypt(data, contentHash) { + const key = deriveChkKey(contentHash); + const zeroNonce = Buffer.alloc(NONCE_SIZE); + const ciphertext = data.subarray(0, data.length - TAG_SIZE); + const authTag = data.subarray(data.length - TAG_SIZE); + const decipher = createDecipheriv("aes-256-gcm", key, zeroNonce); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +for (const testCase of chkVectors.test_vectors) { + const { name, plaintext_hex, content_hash, ciphertext_hex } = testCase; + const plaintext = Buffer.from(plaintext_hex, "hex"); + const expectedHash = content_hash; + const expectedCiphertext = Buffer.from(ciphertext_hex, "hex"); + + // Test 3a: Verify content hash (SHA256 of plaintext) + const computedHash = createHash("sha256").update(plaintext).digest("hex"); + const hashMatch = computedHash === expectedHash; + + // Test 3b: Verify encryption produces identical ciphertext + const computedCiphertext = chkEncrypt(plaintext, Buffer.from(expectedHash, "hex")); + const ciphertextMatch = computedCiphertext.equals(expectedCiphertext); + + // Test 3c: Verify decryption recovers plaintext + let decryptionMatch = false; + try { + const decrypted = chkDecrypt(expectedCiphertext, Buffer.from(expectedHash, "hex")); + decryptionMatch = decrypted.equals(plaintext); + } catch (e) { + decryptionMatch = false; + } + + if (hashMatch && ciphertextMatch && decryptionMatch) { + console.log(` ✓ ${name}`); + console.log(` - SHA256 hash: correct`); + console.log(` - Encryption: byte-identical to Rust`); + console.log(` - Decryption: round-trip verified`); + passed++; + } else { + console.log(` ✗ ${name}`); + if (!hashMatch) { + console.log(` SHA256 mismatch:`); + console.log(` Expected: ${expectedHash}`); + console.log(` Got: ${computedHash}`); + } + if (!ciphertextMatch) { + console.log(` Ciphertext mismatch:`); + console.log(` Expected: ${ciphertext_hex}`); + console.log(` Got: ${computedCiphertext.toString("hex")}`); + } + if (!decryptionMatch) { + console.log(` Decryption failed or mismatch`); + } + failed++; + } +} + // Summary console.log("\n======================================================"); console.log(`\nResults: ${passed} passed, ${failed} failed\n`);