fix(security): apply iOS audit parity fixes (#709)

Co-authored-by: CC <cc@ggg.local>
This commit is contained in:
callebtc
2026-06-15 08:35:37 -05:00
committed by GitHub
co-authored by CC
parent 13585a9a9c
commit 6189f0cb84
6 changed files with 133 additions and 4 deletions
@@ -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")
}
@@ -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")
@@ -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)
}
@@ -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)
}
}