mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 22:45:20 +00:00
fix(security): apply iOS audit parity fixes (#709)
Co-authored-by: CC <cc@ggg.local>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -573,6 +573,11 @@ 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")
|
||||
|
||||
@@ -74,6 +74,11 @@ 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 {
|
||||
@@ -81,6 +86,11 @@ object NostrProtocol {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user