From 47c40fb9f3fecf87935c4e0eb21d2f448061f93a Mon Sep 17 00:00:00 2001 From: shroominic <34897716+shroominic@users.noreply.github.com> Date: Sun, 24 Aug 2025 14:06:29 -0300 Subject: [PATCH] fix nip-17 (#305) * NIP-59 stuff * nip-17 ios to androing works, android to ios still wip * fix impl * ios compat * default relays for DMs * delivery ack works * delivery ack --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> --- .../android/mesh/BluetoothMeshService.kt | 3 +- .../com/bitchat/android/nostr/NostrClient.kt | 17 +- .../com/bitchat/android/nostr/NostrCrypto.kt | 227 ++++++++++++------ .../com/bitchat/android/nostr/NostrEvent.kt | 13 +- .../android/nostr/NostrGeohashService.kt | 41 +++- .../bitchat/android/nostr/NostrProtocol.kt | 50 ++-- .../bitchat/android/nostr/NostrTestManager.kt | 8 +- .../bitchat/android/nostr/NostrTransport.kt | 64 +++-- .../java/com/bitchat/android/ui/ChatHeader.kt | 35 ++- .../bitchat/android/ui/GeohashPeopleList.kt | 2 +- .../bitchat/android/ui/MessageComponents.kt | 6 +- 11 files changed, 308 insertions(+), 158 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index ac1a2888..20cd8902 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -465,7 +465,8 @@ class BluetoothMeshService(private val context: Context) { * Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService */ fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { - if (content.isEmpty() || recipientPeerID.isEmpty() || recipientNickname.isEmpty()) return + if (content.isEmpty() || recipientPeerID.isEmpty()) return + if (!recipientPeerID.startsWith("nostr_") && recipientNickname.isEmpty()) return serviceScope.launch { val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString() diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index 47b7c593..b95f15d7 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -107,17 +107,18 @@ class NostrClient private constructor(private val context: Context) { val recipientPubkeyHex = pubkeyBytes.toHexString() - // Create and send gift wrap - val giftWrap = NostrProtocol.createPrivateMessage( + // Create and send gift wraps (receiver and sender copies) + val giftWraps = NostrProtocol.createPrivateMessage( content = content, recipientPubkey = recipientPubkeyHex, senderIdentity = identity ) - // Track this as a pending gift wrap for logging - NostrRelayManager.registerPendingGiftWrap(giftWrap.id) - - relayManager.sendEvent(giftWrap) + // Track and send all gift wraps + giftWraps.forEach { wrap -> + NostrRelayManager.registerPendingGiftWrap(wrap.id) + relayManager.sendEvent(wrap) + } Log.i(TAG, "πŸ“€ Sent private message to ${recipientNpub.take(16)}...") onSuccess?.invoke() @@ -141,7 +142,7 @@ class NostrClient private constructor(private val context: Context) { val filter = NostrFilter.giftWrapsFor( pubkey = identity.publicKeyHex, - since = System.currentTimeMillis() - 86400000L // Last 24 hours + since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization) ) relayManager.subscribe(filter, "private-messages", { giftWrap -> @@ -241,7 +242,7 @@ class NostrClient private constructor(private val context: Context) { ) { // Age filtering (24h + 15min buffer for randomized timestamps) val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt - if (messageAge > 87300) { // 24 hours + 15 minutes + if (messageAge > 173700) { // 48 hours + 15 minutes Log.v(TAG, "Ignoring old private message") return } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt b/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt index 8706420a..b25499dd 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt @@ -7,14 +7,11 @@ import org.bouncycastle.crypto.params.ECPublicKeyParameters import org.bouncycastle.math.ec.ECPoint import org.bouncycastle.crypto.generators.ECKeyPairGenerator import org.bouncycastle.crypto.params.ECKeyGenerationParameters -import org.bouncycastle.crypto.AsymmetricCipherKeyPair import org.bouncycastle.crypto.agreement.ECDHBasicAgreement import org.bouncycastle.crypto.digests.SHA256Digest import org.bouncycastle.crypto.macs.HMac import org.bouncycastle.crypto.params.KeyParameter -import javax.crypto.Cipher -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec +import com.google.crypto.tink.subtle.XChaCha20Poly1305 import java.security.SecureRandom import java.security.MessageDigest import java.math.BigInteger @@ -26,6 +23,7 @@ import java.math.BigInteger object NostrCrypto { private val secureRandom = SecureRandom() + // NIP-44 v2 only // secp256k1 curve parameters val secp256k1Curve = CustomNamedCurves.getByName("secp256k1") @@ -99,7 +97,7 @@ object NostrCrypto { val privateKeyBigInt = BigInteger(1, privateKeyBytes) val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, secp256k1Params) - // Try to recover full public key point from x-only coordinate + // Recover full public key point from x-only coordinate (prefer even y per BIP-340) val publicKeyPoint = recoverPublicKeyPoint(publicKeyBytes) val publicKeyParams = ECPublicKeyParameters(publicKeyPoint, secp256k1Params) @@ -119,10 +117,38 @@ object NostrCrypto { maxOf(0, 32 - sharedSecretBytes.size), minOf(sharedSecretBytes.size, 32) ) + } else { + // If BigInteger added a leading sign byte, keep the last 32 bytes + System.arraycopy(sharedSecretBytes, sharedSecretBytes.size - 32, result, 0, 32) } return result } + + /** + * Perform ECDH with explicit parity choice for the x-only public key + * If preferOddY is true, use the odd-y lift; otherwise even-y lift. + */ + private fun performECDHWithParity(privateKeyHex: String, publicKeyHex: String, preferOddY: Boolean): ByteArray { + val privateKeyBytes = privateKeyHex.hexToByteArray() + val publicKeyBytes = publicKeyHex.hexToByteArray() + val privateKeyBigInt = BigInteger(1, privateKeyBytes) + val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, secp256k1Params) + val point = recoverPublicKeyPointWithParity(publicKeyBytes, preferOddY) + val publicKeyParams = ECPublicKeyParameters(point, secp256k1Params) + val agreement = ECDHBasicAgreement() + agreement.init(privateKeyParams) + val sharedSecret = agreement.calculateAgreement(publicKeyParams) + val sharedSecretBytes = sharedSecret.toByteArray() + val result = ByteArray(32) + if (sharedSecretBytes.size <= 32) { + System.arraycopy(sharedSecretBytes, maxOf(0, sharedSecretBytes.size - 32), result, maxOf(0, 32 - sharedSecretBytes.size), minOf(sharedSecretBytes.size, 32)) + } else { + // If BigInteger added a leading sign byte, keep the last 32 bytes + System.arraycopy(sharedSecretBytes, sharedSecretBytes.size - 32, result, 0, 32) + } + return result + } /** * Recover full EC point from x-only coordinate @@ -147,96 +173,138 @@ object NostrCrypto { return secp256k1Curve.curve.decodePoint(compressedBytes) } } + + private fun recoverPublicKeyPointWithParity(xOnlyBytes: ByteArray, preferOddY: Boolean): ECPoint { + require(xOnlyBytes.size == 32) { "X-only public key must be 32 bytes" } + val prefix: Byte = if (preferOddY) 0x03 else 0x02 + val compressedBytes = ByteArray(33) + compressedBytes[0] = prefix + System.arraycopy(xOnlyBytes, 0, compressedBytes, 1, 32) + return secp256k1Curve.curve.decodePoint(compressedBytes) + } /** - * NIP-44 key derivation using HKDF + * Compute the ECDH shared point (uncompressed) with an explicit parity choice for the x-only public key + */ + private fun computeSharedPointWithParity(privateKeyHex: String, publicKeyHex: String, preferOddY: Boolean): ECPoint { + val privateKeyBytes = privateKeyHex.hexToByteArray() + val publicKeyBytes = publicKeyHex.hexToByteArray() + val privateKeyBigInt = BigInteger(1, privateKeyBytes) + val point = recoverPublicKeyPointWithParity(publicKeyBytes, preferOddY) + return point.multiply(privateKeyBigInt).normalize() + } + + private fun compressedPoint(point: ECPoint): ByteArray { + val normalized = point.normalize() + val x = normalized.xCoord.encoded + val prefix: Byte = if (hasEvenY(normalized)) 0x02 else 0x03 + val out = ByteArray(33) + out[0] = prefix + System.arraycopy(x, 0, out, 1, 32) + return out + } + + /** + * NIP-44 v2 key derivation using HKDF-SHA256 + * salt = empty, info = "nip44-v2", length = 32 bytes */ fun deriveNIP44Key(sharedSecret: ByteArray): ByteArray { - val salt = "nip44-v2".toByteArray(Charsets.UTF_8) - - // HKDF-Extract + val zeroSalt = ByteArray(0) + val prk = hkdfExtract(zeroSalt, sharedSecret) + return hkdfExpand(prk, info = "nip44-v2".toByteArray(Charsets.UTF_8), length = 32) + } + + private fun hkdfExtract(salt: ByteArray, ikm: ByteArray): ByteArray { val hmac = HMac(SHA256Digest()) hmac.init(KeyParameter(salt)) - hmac.update(sharedSecret, 0, sharedSecret.size) + hmac.update(ikm, 0, ikm.size) val prk = ByteArray(hmac.macSize) hmac.doFinal(prk, 0) - - // HKDF-Expand (we need 32 bytes for AES-256) - hmac.init(KeyParameter(prk)) - hmac.update(byteArrayOf(0x01), 0, 1) // info = empty, N = 1 - val okm = ByteArray(hmac.macSize) - hmac.doFinal(okm, 0) - - // Return first 32 bytes - return okm.copyOf(32) + return prk } - + + private fun hkdfExpand(prk: ByteArray, info: ByteArray?, length: Int): ByteArray { + val hmac = HMac(SHA256Digest()) + hmac.init(KeyParameter(prk)) + if (info != null && info.isNotEmpty()) { + hmac.update(info, 0, info.size) + } + hmac.update(byteArrayOf(0x01), 0, 1) + val t = ByteArray(hmac.macSize) + hmac.doFinal(t, 0) + return t.copyOf(length) + } + /** - * NIP-44 encryption using AES-256-GCM + * NIP-44 v2 encryption using XChaCha20-Poly1305 + * Output format: "v2:" + base64url(nonce24 || ciphertext || tag) */ - fun encryptNIP44(plaintext: String, recipientPublicKeyHex: String, senderPrivateKeyHex: String): String { + fun encryptNIP44( + plaintext: String, + recipientPublicKeyHex: String, + senderPrivateKeyHex: String + ): String { try { - // Perform ECDH - val sharedSecret = performECDH(senderPrivateKeyHex, recipientPublicKeyHex) - - // Derive encryption key - val encryptionKey = deriveNIP44Key(sharedSecret) - - // Generate random nonce (12 bytes for GCM) - val nonce = ByteArray(12) - secureRandom.nextBytes(nonce) - - // Encrypt using AES-256-GCM - val plaintextBytes = plaintext.toByteArray(Charsets.UTF_8) - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - val secretKey = SecretKeySpec(encryptionKey, "AES") - val gcmSpec = GCMParameterSpec(128, nonce) // 128-bit auth tag - - cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec) - val ciphertext = cipher.doFinal(plaintextBytes) - - // Combine nonce + ciphertext (includes auth tag) - val result = ByteArray(nonce.size + ciphertext.size) - System.arraycopy(nonce, 0, result, 0, nonce.size) - System.arraycopy(ciphertext, 0, result, nonce.size, ciphertext.size) - - // Base64 encode - return android.util.Base64.encodeToString(result, android.util.Base64.NO_WRAP) + // Match iOS: derive HKDF input from the compressed shared point (33 bytes) + val sharedPoint = computeSharedPointWithParity(senderPrivateKeyHex, recipientPublicKeyHex, preferOddY = false) + val secretMaterial = compressedPoint(sharedPoint) + val encryptionKey = deriveNIP44Key(secretMaterial) + val aead = XChaCha20Poly1305(encryptionKey) + val combined = aead.encrypt(plaintext.toByteArray(Charsets.UTF_8), null) // nonce||ct||tag + val b64 = base64UrlNoPad(combined) + android.util.Log.d("NostrCrypto", "NIP44 v2 encrypt: len=${b64.length}") + return "v2:$b64" } catch (e: Exception) { - throw RuntimeException("NIP-44 encryption failed: ${e.message}", e) + throw RuntimeException("NIP-44 v2 encryption failed: ${e.message}", e) } } - + /** - * NIP-44 decryption using AES-256-GCM + * NIP-44 v2 decryption using XChaCha20-Poly1305 + * Only accepts the exact "v2:" base64url format. + * Tries both even/odd Y parities for x-only pubkeys. */ fun decryptNIP44(ciphertext: String, senderPublicKeyHex: String, recipientPrivateKeyHex: String): String { try { - // Decode base64 - val encryptedData = android.util.Base64.decode(ciphertext, android.util.Base64.NO_WRAP) - - // Extract nonce and ciphertext - require(encryptedData.size >= 12 + 16) { "Ciphertext too short" } - val nonce = encryptedData.copyOfRange(0, 12) - val ciphertextBytes = encryptedData.copyOfRange(12, encryptedData.size) - - // Perform ECDH - val sharedSecret = performECDH(recipientPrivateKeyHex, senderPublicKeyHex) - - // Derive decryption key - val decryptionKey = deriveNIP44Key(sharedSecret) - - // Decrypt using AES-256-GCM - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - val secretKey = SecretKeySpec(decryptionKey, "AES") - val gcmSpec = GCMParameterSpec(128, nonce) - - cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec) - val plaintextBytes = cipher.doFinal(ciphertextBytes) - - return String(plaintextBytes, Charsets.UTF_8) + require(ciphertext.startsWith("v2:")) { "Invalid NIP-44 version prefix" } + val encoded = ciphertext.substring(3) + val encryptedData = base64UrlDecode(encoded) + ?: throw IllegalArgumentException("Invalid base64url payload") + + var lastError: Exception? = null + // Try even-Y first, then odd-Y + for (preferOdd in listOf(false, true)) { + try { + // Match iOS: derive HKDF input from the compressed shared point (33 bytes) + val point = computeSharedPointWithParity(recipientPrivateKeyHex, senderPublicKeyHex, preferOddY = preferOdd) + val secretMaterial = compressedPoint(point) + val key = deriveNIP44Key(secretMaterial) + val aead = XChaCha20Poly1305(key) + val pt = aead.decrypt(encryptedData, null) // expects nonce||ct||tag + return String(pt, Charsets.UTF_8) + } catch (e: Exception) { + lastError = e + } + } + throw lastError ?: RuntimeException("NIP-44 v2 decryption failed") } catch (e: Exception) { - throw RuntimeException("NIP-44 decryption failed: ${e.message}", e) + throw RuntimeException("NIP-44 v2 decryption failed: ${e.message}", e) + } + } + + private fun base64UrlNoPad(data: ByteArray): String { + val b64 = android.util.Base64.encodeToString(data, android.util.Base64.NO_WRAP) + return b64.replace('+', '-').replace('/', '_').replace("=", "") + } + + private fun base64UrlDecode(s: String): ByteArray? { + var str = s.replace('-', '+').replace('_', '/') + val pad = (4 - (str.length % 4)) % 4 + if (pad > 0) str += "=".repeat(pad) + return try { + android.util.Base64.decode(str, android.util.Base64.NO_WRAP) + } catch (_: IllegalArgumentException) { + null } } @@ -248,6 +316,15 @@ object NostrCrypto { return (baseTimestamp + offset).toInt() } + /** + * Random timestamp up to maxPastSeconds in the past (default 2 days) + */ + fun randomizeTimestampUpToPast(maxPastSeconds: Int = 172800): Int { + val now = (System.currentTimeMillis() / 1000).toInt() + val offset = if (maxPastSeconds > 0) secureRandom.nextInt(maxPastSeconds + 1) else 0 + return now - offset + } + /** * Validate secp256k1 private key */ diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt index 29257481..785b41f3 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt @@ -1,6 +1,7 @@ package com.bitchat.android.nostr import com.google.gson.Gson +import com.google.gson.GsonBuilder import com.google.gson.annotations.SerializedName import java.security.MessageDigest @@ -106,6 +107,14 @@ data class NostrEvent( ) } + /** + * Compute event ID (NIP-01) without signing + */ + fun computeEventIdHex(): String { + val (eventId, _) = calculateEventId() + return eventId + } + /** * Calculate event ID according to NIP-01 * Returns (hex_id, hash_bytes) @@ -122,7 +131,7 @@ data class NostrEvent( ) // Convert to JSON without escaping slashes (compact format) - val gson = Gson() + val gson = GsonBuilder().disableHtmlEscaping().create() val jsonString = gson.toJson(serialized) // SHA256 hash of the JSON string @@ -201,6 +210,8 @@ data class NostrEvent( object NostrKind { const val METADATA = 0 const val TEXT_NOTE = 1 + const val DIRECT_MESSAGE = 14 // NIP-17 direct message (unsigned) + const val FILE_MESSAGE = 15 // NIP-17 file message (unsigned) const val SEAL = 13 // NIP-17 sealed event const val GIFT_WRAP = 1059 // NIP-17 gift wrap const val EPHEMERAL_EVENT = 20000 // For geohash channels diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt index 4bccf5de..d0fc3c0a 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt @@ -119,7 +119,7 @@ class NostrGeohashService( // Subscribe to gift wraps (NIP-17 private messages) val dmFilter = NostrFilter.giftWrapsFor( pubkey = currentIdentity.publicKeyHex, - since = System.currentTimeMillis() - 86400000L // Last 24 hours + since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization) ) nostrRelayManager.subscribe( @@ -234,7 +234,7 @@ class NostrGeohashService( // Client-side filtering: ignore messages older than 24 hours + 15 minutes buffer val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt - if (messageAge > 87300) { // 24 hours + 15 minutes + if (messageAge > 173700) { // 48 hours + 15 minutes return } @@ -316,7 +316,27 @@ class NostrGeohashService( // Store Nostr key mapping nostrKeyMapping[targetPeerID] = senderPubkey + // Process payload and update UI/state processNoisePayload(noisePayload, targetPeerID, senderNickname, messageTimestamp) + + // If this was a private message, send a delivery ACK back over Nostr + if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) { + val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) + pm?.let { pmsg -> + val nostrTransport = NostrTransport.getInstance(application) + // Prefer mapped peer route; fallback to direct Nostr using sender pubkey + if (senderNoiseKey != null) { + val peerIdHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) } + nostrTransport.sendDeliveryAck(pmsg.messageID, peerIdHex) + } else { + // Fallback: direct to sender’s Nostr pubkey (geohash-style) + val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) + if (identity != null) { + nostrTransport.sendDeliveryAckGeohash(pmsg.messageID, senderPubkey, identity) + } + } + } + } } catch (e: Exception) { Log.e(TAG, "Error processing Nostr message: ${e.message}") @@ -900,18 +920,17 @@ class NostrGeohashService( val dmFilter = NostrFilter.giftWrapsFor( pubkey = dmIdentity.publicKeyHex, - since = System.currentTimeMillis() - 86400000L // Last 24 hours + since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization) ) - nostrRelayManager.subscribeForGeohash( - geohash = channel.channel.geohash, + // IMPORTANT: For geohash DMs, use default relays (iOS behavior) + nostrRelayManager.subscribe( filter = dmFilter, id = dmSubId, handler = { giftWrap -> handleGeohashDmEvent(giftWrap, channel.channel.geohash, dmIdentity) }, - includeDefaults = false, - nRelays = 5 + targetRelayUrls = null ) Log.i(TAG, "βœ… Subscribed to geohash DMs for identity: ${dmIdentity.publicKeyHex.take(16)}...") @@ -1177,7 +1196,7 @@ class NostrGeohashService( ) if (decryptResult == null) { - Log.w(TAG, "Failed to decrypt geohash DM") + Log.d(TAG, "Skipping geohash DM: unwrap/open failed (non-fatal)") return } @@ -1235,7 +1254,11 @@ class NostrGeohashService( // Add to private chats privateChatManager.handleIncomingPrivateMessage(message) - + + // Always send delivery ACK for geohash DMs + val nostrTransport = NostrTransport.getInstance(application) + nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, identity) + // Send read receipt if viewing this chat if (isViewingThisChat) { // Send read receipt via Nostr for geohash DM diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 744a5edb..5942e0a7 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -14,46 +14,42 @@ object NostrProtocol { private val gson = Gson() /** - * Create a NIP-17 private message - * Returns gift-wrapped event ready for relay broadcast + * Create NIP-17 private message gift-wrap (receiver copy only per iOS) + * Returns a single gift-wrapped event ready for relay broadcast */ fun createPrivateMessage( content: String, recipientPubkey: String, senderIdentity: NostrIdentity - ): NostrEvent { - Log.v(TAG, "Creating private message for recipient: ${recipientPubkey.take(16)}...") + ): List { + Log.d(TAG, "Creating private message for recipient: ${recipientPubkey.take(16)}...") - // 1. Create the rumor (unsigned event) - val rumor = NostrEvent( + // 1. Create the rumor (unsigned kind 14) with p-tag + val rumorBase = NostrEvent( pubkey = senderIdentity.publicKeyHex, createdAt = (System.currentTimeMillis() / 1000).toInt(), - kind = NostrKind.TEXT_NOTE, - tags = emptyList(), + kind = NostrKind.DIRECT_MESSAGE, + tags = listOf(listOf("p", recipientPubkey)), content = content ) + val rumorId = rumorBase.computeEventIdHex() + val rumor = rumorBase.copy(id = rumorId) - // 2. Create ephemeral key for this message - val (ephemeralPrivateKey, ephemeralPublicKey) = NostrCrypto.generateKeyPair() - Log.v(TAG, "Created ephemeral key for seal") - - // 3. Seal the rumor (encrypt to recipient) + // 2. Seal the rumor (kind 13) signed by sender, timestamp randomized up to 2 days val sealedEvent = createSeal( rumor = rumor, recipientPubkey = recipientPubkey, - senderPrivateKey = ephemeralPrivateKey, - senderPublicKey = ephemeralPublicKey + senderPrivateKey = senderIdentity.privateKeyHex, + senderPublicKey = senderIdentity.publicKeyHex ) - // 4. Gift wrap the sealed event (encrypt to recipient again) - val giftWrap = createGiftWrap( + // 3. Gift wrap to recipient (kind 1059) + val giftWrapToRecipient = createGiftWrap( seal = sealedEvent, recipientPubkey = recipientPubkey ) - - Log.v(TAG, "Created gift wrap with id: ${giftWrap.id.take(16)}...") - - return giftWrap + Log.d(TAG, "Created gift wrap: toRecipient=${giftWrapToRecipient.id.take(16)}...") + return listOf(giftWrapToRecipient) } /** @@ -87,7 +83,7 @@ object NostrProtocol { Triple(rumor.content, rumor.pubkey, rumor.createdAt) } catch (e: Exception) { - Log.e(TAG, "❌ Failed to decrypt private message: ${e.message}") + Log.w(TAG, "Failed to decrypt private message: ${e.message}") null } } @@ -143,7 +139,7 @@ object NostrProtocol { val seal = NostrEvent( pubkey = senderPublicKey, - createdAt = NostrCrypto.randomizeTimestamp(), + createdAt = NostrCrypto.randomizeTimestampUpToPast(), kind = NostrKind.SEAL, tags = emptyList(), content = encrypted @@ -172,7 +168,7 @@ object NostrProtocol { val giftWrap = NostrEvent( pubkey = wrapPublicKey, - createdAt = NostrCrypto.randomizeTimestamp(), + createdAt = NostrCrypto.randomizeTimestampUpToPast(), kind = NostrKind.GIFT_WRAP, tags = listOf(listOf("p", recipientPubkey)), // Tag recipient content = encrypted @@ -186,7 +182,7 @@ object NostrProtocol { giftWrap: NostrEvent, recipientPrivateKey: String ): NostrEvent? { - Log.v(TAG, "Unwrapping gift wrap") + Log.d(TAG, "Unwrapping gift wrap; content prefix='${giftWrap.content.take(3)}' length=${giftWrap.content.length}") return try { val decrypted = NostrCrypto.decryptNIP44( @@ -215,7 +211,7 @@ object NostrProtocol { Log.v(TAG, "Unwrapped seal with kind: ${seal.kind}") seal } catch (e: Exception) { - Log.e(TAG, "Failed to unwrap gift wrap: ${e.message}") + Log.w(TAG, "Failed to unwrap gift wrap: ${e.message}") null } } @@ -248,7 +244,7 @@ object NostrProtocol { sig = jsonObject.get("sig")?.asString ) } catch (e: Exception) { - Log.e(TAG, "Failed to open seal: ${e.message}") + Log.w(TAG, "Failed to open seal: ${e.message}") null } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt index b818eba3..66f3b095 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt @@ -122,9 +122,13 @@ class NostrTestManager(private val context: Context) { val (recipientPrivate, recipientPublic) = NostrCrypto.generateKeyPair() val plaintext = "Hello, Nostr world! This is a test message." - val encrypted = NostrCrypto.encryptNIP44(plaintext, recipientPublic, privateKey) + val encrypted = NostrCrypto.encryptNIP44( + plaintext, + recipientPublic, + privateKey + ) require(encrypted.isNotEmpty()) { "Encryption failed" } - + val decrypted = NostrCrypto.decryptNIP44(encrypted, publicKey, recipientPrivate) require(decrypted == plaintext) { "Decryption failed: expected '$plaintext', got '$decrypted'" } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index 6c935ad2..d98d445f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -98,14 +98,16 @@ class NostrTransport( return@launch } - val event = NostrProtocol.createPrivateMessage( + val giftWraps = NostrProtocol.createPrivateMessage( content = embedded, recipientPubkey = recipientHex, senderIdentity = senderIdentity ) - Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...") - NostrRelayManager.getInstance(context).sendEvent(event) + giftWraps.forEach { event -> + Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...") + NostrRelayManager.getInstance(context).sendEvent(event) + } } catch (e: Exception) { Log.e(TAG, "Failed to send private message via Nostr: ${e.message}") @@ -182,14 +184,16 @@ class NostrTransport( return@launch } - val event = NostrProtocol.createPrivateMessage( + val giftWraps = NostrProtocol.createPrivateMessage( content = ack, recipientPubkey = recipientHex, senderIdentity = senderIdentity ) - Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...") - NostrRelayManager.getInstance(context).sendEvent(event) + giftWraps.forEach { event -> + Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...") + NostrRelayManager.getInstance(context).sendEvent(event) + } scheduleNextReadAck() @@ -256,14 +260,16 @@ class NostrTransport( return@launch } - val event = NostrProtocol.createPrivateMessage( + val giftWraps = NostrProtocol.createPrivateMessage( content = embedded, recipientPubkey = recipientHex, senderIdentity = senderIdentity ) - Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...") - NostrRelayManager.getInstance(context).sendEvent(event) + giftWraps.forEach { event -> + Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...") + NostrRelayManager.getInstance(context).sendEvent(event) + } } catch (e: Exception) { Log.e(TAG, "Failed to send favorite notification via Nostr: ${e.message}") @@ -312,14 +318,16 @@ class NostrTransport( return@launch } - val event = NostrProtocol.createPrivateMessage( + val giftWraps = NostrProtocol.createPrivateMessage( content = ack, recipientPubkey = recipientHex, senderIdentity = senderIdentity ) - Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...") - NostrRelayManager.getInstance(context).sendEvent(event) + giftWraps.forEach { event -> + Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...") + NostrRelayManager.getInstance(context).sendEvent(event) + } } catch (e: Exception) { Log.e(TAG, "Failed to send delivery ack via Nostr: ${e.message}") @@ -346,15 +354,17 @@ class NostrTransport( if (embedded == null) return@launch - val event = NostrProtocol.createPrivateMessage( + val giftWraps = NostrProtocol.createPrivateMessage( content = embedded, recipientPubkey = toRecipientHex, senderIdentity = fromIdentity ) - // Register pending gift wrap for deduplication (like iOS) - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) + // Register pending gift wrap for deduplication and send all + giftWraps.forEach { event -> + NostrRelayManager.registerPendingGiftWrap(event.id) + NostrRelayManager.getInstance(context).sendEvent(event) + } } catch (e: Exception) { Log.e(TAG, "Failed to send geohash delivery ack: ${e.message}") @@ -379,15 +389,17 @@ class NostrTransport( if (embedded == null) return@launch - val event = NostrProtocol.createPrivateMessage( + val giftWraps = NostrProtocol.createPrivateMessage( content = embedded, recipientPubkey = toRecipientHex, senderIdentity = fromIdentity ) - // Register pending gift wrap for deduplication (like iOS) - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) + // Register pending gift wrap for deduplication and send all + giftWraps.forEach { event -> + NostrRelayManager.registerPendingGiftWrap(event.id) + NostrRelayManager.getInstance(context).sendEvent(event) + } } catch (e: Exception) { Log.e(TAG, "Failed to send geohash read receipt: ${e.message}") @@ -421,17 +433,17 @@ class NostrTransport( return@launch } - val event = NostrProtocol.createPrivateMessage( + val giftWraps = NostrProtocol.createPrivateMessage( content = embedded, recipientPubkey = toRecipientHex, senderIdentity = fromIdentity ) - Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...") - - // Register pending gift wrap for deduplication (like iOS) - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) + giftWraps.forEach { event -> + Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...") + NostrRelayManager.registerPendingGiftWrap(event.id) + NostrRelayManager.getInstance(context).sendEvent(event) + } } catch (e: Exception) { Log.e(TAG, "Failed to send geohash private message: ${e.message}") diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 7c5a1c95..20cab350 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -257,11 +257,17 @@ fun ChatHeaderContent( Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState") + // Pass geohash context and people for NIP-17 chat title formatting + val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() + val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) + PrivateChatHeader( peerID = selectedPrivatePeer, peerNicknames = peerNicknames, isFavorite = isFavorite, sessionState = sessionState, + selectedLocationChannel = selectedLocationChannel, + geohashPeople = geohashPeople, onBackClick = onBackClick, onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) } ) @@ -296,11 +302,25 @@ private fun PrivateChatHeader( peerNicknames: Map, isFavorite: Boolean, sessionState: String?, + selectedLocationChannel: com.bitchat.android.geohash.ChannelID?, + geohashPeople: List, onBackClick: () -> Unit, onToggleFavorite: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - val peerNickname = peerNicknames[peerID] ?: peerID + val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") + + // Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity) + val titleText: String = if (isNostrDM) { + val geohash = (selectedLocationChannel as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash + val shortId = peerID.removePrefix("nostr_").removePrefix("nostr:") + val person = geohashPeople.firstOrNull { it.id.startsWith(shortId, ignoreCase = true) } + val baseName = person?.displayName?.substringBefore('#') ?: peerNicknames[peerID] ?: "unknown" + val geoPart = geohash?.let { "#$it" } ?: "#geohash" + "$geoPart/@$baseName" + } else { + peerNicknames[peerID] ?: peerID + } Box(modifier = Modifier.fillMaxWidth()) { // Back button - positioned all the way to the left with minimal margin @@ -340,17 +360,20 @@ private fun PrivateChatHeader( ) { Text( - text = peerNickname, + text = titleText, style = MaterialTheme.typography.titleMedium, color = Color(0xFFFF9500) // Orange ) Spacer(modifier = Modifier.width(4.dp)) - NoiseSessionIcon( - sessionState = sessionState, - modifier = Modifier.size(14.dp) - ) + // For NIP-17 chats, do not show Noise session state icon (remove warning icon) + if (!isNostrDM) { + NoiseSessionIcon( + sessionState = sessionState, + modifier = Modifier.size(14.dp) + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 382874c8..9265e3d1 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -133,7 +133,7 @@ fun GeohashPeopleList( if (person.id != myHex) { // TODO: Re-enable when NIP-17 geohash DM issues are fixed // Start geohash DM (iOS-compatible) - // viewModel.startGeohashDM(person.id) + viewModel.startGeohashDM(person.id) onTapPerson() } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 75a78fcc..b2e494a6 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -271,15 +271,17 @@ fun DeliveryStatusIcon(status: DeliveryStatus) { ) } is DeliveryStatus.Sent -> { + // Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity) Text( - text = "βœ“", + text = "β—‹", fontSize = 10.sp, color = colorScheme.primary.copy(alpha = 0.6f) ) } is DeliveryStatus.Delivered -> { + // Single check for Delivered (matches iOS expectations) Text( - text = "βœ“βœ“", + text = "βœ“", fontSize = 10.sp, color = colorScheme.primary.copy(alpha = 0.8f) )