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() {
|
fun clearPersistedChannel() {
|
||||||
dataManager?.clearLastGeohashChannel()
|
dataManager?.clearLastGeohashChannel()
|
||||||
_selectedChannel.value = ChannelID.Mesh
|
_selectedChannel.value = ChannelID.Mesh
|
||||||
|
_teleported.value = false
|
||||||
Log.d(TAG, "Cleared persisted channel selection")
|
Log.d(TAG, "Cleared persisted channel selection")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -572,7 +572,12 @@ class NoiseSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val (extractedNonce, ciphertext) = nonceAndCiphertext
|
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
|
// Validate nonce with sliding window replay protection
|
||||||
if (!isValidNonce(extractedNonce, highestReceivedNonce, replayWindow)) {
|
if (!isValidNonce(extractedNonce, highestReceivedNonce, replayWindow)) {
|
||||||
Log.w(TAG, "Replay attack detected: nonce $extractedNonce rejected for $peerID")
|
Log.w(TAG, "Replay attack detected: nonce $extractedNonce rejected for $peerID")
|
||||||
|
|||||||
@@ -73,14 +73,24 @@ object NostrProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...")
|
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
|
// 2. Open the seal
|
||||||
val rumor = openSeal(seal, recipientIdentity.privateKeyHex)
|
val rumor = openSeal(seal, recipientIdentity.privateKeyHex)
|
||||||
?: run {
|
?: run {
|
||||||
Log.w(TAG, "❌ Failed to open seal")
|
Log.w(TAG, "❌ Failed to open seal")
|
||||||
return null
|
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")
|
Log.v(TAG, "Successfully opened seal")
|
||||||
|
|
||||||
Triple(rumor.content, rumor.pubkey, rumor.createdAt)
|
Triple(rumor.content, rumor.pubkey, rumor.createdAt)
|
||||||
@@ -227,7 +237,7 @@ object NostrProtocol {
|
|||||||
content = encrypted
|
content = encrypted
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sign with the ephemeral key
|
// NIP-17 requires the seal to be signed by the sender identity key.
|
||||||
return seal.sign(senderPrivateKey)
|
return seal.sign(senderPrivateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -938,6 +938,11 @@ class ChatViewModel(
|
|||||||
store.clearAll()
|
store.clearAll()
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
|
try {
|
||||||
|
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
||||||
|
locationManager.clearPersistedChannel()
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
geohashViewModel.panicReset()
|
geohashViewModel.panicReset()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
|
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