From 5db58ad7cac033e8f3c463ed77a30913f81129d1 Mon Sep 17 00:00:00 2001 From: CC Date: Mon, 15 Jun 2026 14:45:46 +0200 Subject: [PATCH] fix(security): apply iOS audit parity fixes --- .../android/geohash/LocationChannelManager.kt | 1 + .../com/bitchat/android/noise/NoiseSession.kt | 7 +- .../bitchat/android/nostr/NostrProtocol.kt | 16 +++- .../com/bitchat/android/ui/ChatViewModel.kt | 5 ++ app/src/test/kotlin/android/util/Base64.kt | 23 +++++ .../android/nostr/NostrProtocolTest.kt | 85 +++++++++++++++++++ 6 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 app/src/test/kotlin/android/util/Base64.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index 222cc54e..ea595697 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -539,6 +539,7 @@ class LocationChannelManager private constructor(private val context: Context) { fun clearPersistedChannel() { dataManager?.clearLastGeohashChannel() _selectedChannel.value = ChannelID.Mesh + _teleported.value = false Log.d(TAG, "Cleared persisted channel selection") } diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt index dc4a3d50..332dc78a 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt @@ -572,7 +572,12 @@ class NoiseSession( } val (extractedNonce, ciphertext) = nonceAndCiphertext - + + if (ciphertext.size < receiveCipher!!.macLength) { + Log.w(TAG, "Ciphertext too short: ${ciphertext.size} < ${receiveCipher!!.macLength}") + throw SessionError.DecryptionFailed + } + // Validate nonce with sliding window replay protection if (!isValidNonce(extractedNonce, highestReceivedNonce, replayWindow)) { Log.w(TAG, "Replay attack detected: nonce $extractedNonce rejected for $peerID") 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 1e0b51f6..501cf60c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -73,14 +73,24 @@ object NostrProtocol { } Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...") - + + if (seal.kind != NostrKind.SEAL || !seal.isValidSignature()) { + Log.w(TAG, "❌ Invalid NIP-17 seal signature") + return null + } + // 2. Open the seal val rumor = openSeal(seal, recipientIdentity.privateKeyHex) ?: run { Log.w(TAG, "❌ Failed to open seal") return null } - + + if (seal.pubkey != rumor.pubkey) { + Log.w(TAG, "❌ NIP-17 seal pubkey does not match rumor pubkey") + return null + } + Log.v(TAG, "Successfully opened seal") Triple(rumor.content, rumor.pubkey, rumor.createdAt) @@ -227,7 +237,7 @@ object NostrProtocol { content = encrypted ) - // Sign with the ephemeral key + // NIP-17 requires the seal to be signed by the sender identity key. return seal.sign(senderPrivateKey) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 786e7e91..a1f13a7a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -938,6 +938,11 @@ class ChatViewModel( store.clearAll() } catch (_: Exception) { } + try { + val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) + locationManager.clearPersistedChannel() + } catch (_: Exception) { } + geohashViewModel.panicReset() } catch (e: Exception) { Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") diff --git a/app/src/test/kotlin/android/util/Base64.kt b/app/src/test/kotlin/android/util/Base64.kt new file mode 100644 index 00000000..a997497a --- /dev/null +++ b/app/src/test/kotlin/android/util/Base64.kt @@ -0,0 +1,23 @@ +@file:JvmName("Base64") + +package android.util + +const val DEFAULT: Int = 0 +const val NO_WRAP: Int = 2 + +fun encodeToString(input: ByteArray, flags: Int): String { + val encoder = if (flags and NO_WRAP != 0) { + java.util.Base64.getEncoder() + } else { + java.util.Base64.getMimeEncoder() + } + return encoder.encodeToString(input) +} + +fun decode(input: String, flags: Int): ByteArray { + return if (flags and NO_WRAP != 0) { + java.util.Base64.getDecoder().decode(input) + } else { + java.util.Base64.getMimeDecoder().decode(input) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt new file mode 100644 index 00000000..a5bd9561 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt @@ -0,0 +1,85 @@ +package com.bitchat.android.nostr + +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NostrProtocolTest { + private val gson = Gson() + + @Test + fun decryptPrivateMessage_acceptsAuthenticatedSeal() { + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val giftWrap = NostrProtocol.createPrivateMessage( + content = "bitchat1:test", + recipientPubkey = recipient.publicKeyHex, + senderIdentity = sender + ).single() + + val decrypted = NostrProtocol.decryptPrivateMessage(giftWrap, recipient) + + assertEquals("bitchat1:test", decrypted?.first) + assertEquals(sender.publicKeyHex, decrypted?.second) + } + + @Test + fun decryptPrivateMessage_rejectsSealWhoseSignerDoesNotMatchRumor() { + val claimedSender = NostrIdentity.generate() + val attacker = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val giftWrap = forgedGiftWrap( + content = "bitchat1:forged", + claimedSender = claimedSender, + sealSigner = attacker, + recipient = recipient + ) + + val decrypted = NostrProtocol.decryptPrivateMessage(giftWrap, recipient) + + assertNull(decrypted) + } + + private fun forgedGiftWrap( + content: String, + claimedSender: NostrIdentity, + sealSigner: NostrIdentity, + recipient: NostrIdentity + ): NostrEvent { + val rumorBase = NostrEvent( + pubkey = claimedSender.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.DIRECT_MESSAGE, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = content + ) + val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex()) + val sealContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(rumor), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = sealSigner.privateKeyHex + ) + val seal = NostrEvent( + pubkey = sealSigner.publicKeyHex, + createdAt = NostrCrypto.randomizeTimestampUpToPast(), + kind = NostrKind.SEAL, + tags = emptyList(), + content = sealContent + ).sign(sealSigner.privateKeyHex) + + val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair() + val giftWrapContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(seal), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = wrapPrivateKey + ) + return NostrEvent( + pubkey = wrapPublicKey, + createdAt = NostrCrypto.randomizeTimestampUpToPast(), + kind = NostrKind.GIFT_WRAP, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = giftWrapContent + ).sign(wrapPrivateKey) + } +}