Authenticate private media capability per Noise session

This commit is contained in:
jack
2026-07-12 12:00:35 -04:00
parent 64ed730bf2
commit eb55cdb6a7
31 changed files with 2121 additions and 408 deletions
@@ -8,6 +8,8 @@ import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.bitchat.android.noise.NoiseEncryptionService
import com.bitchat.android.noise.NoiseHandshakeProcessingResult
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoiseDecryptionResult
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
@@ -191,6 +193,13 @@ open class EncryptionService(private val context: Context) {
}
return encrypted
}
@Throws(Exception::class)
fun encryptForSession(
data: ByteArray,
peerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray = noiseService.encryptForSession(data, peerID, expectedSession)
/**
* Decrypt data from a specific peer using Noise transport encryption
@@ -203,6 +212,12 @@ open class EncryptionService(private val context: Context) {
}
return decrypted
}
@Throws(Exception::class)
fun decryptWithSession(data: ByteArray, peerID: String): NoiseDecryptionResult {
return noiseService.decryptWithSession(data, peerID)
?: throw Exception("Failed generation-bound decryption from $peerID")
}
/**
* Sign data using our static identity key
@@ -263,11 +278,17 @@ open class EncryptionService(private val context: Context) {
* authentication, not a self-certified identity payload.
*/
fun getAuthenticatedRemoteStaticKey(peerID: String): ByteArray? {
if (!noiseService.hasEstablishedSession(peerID)) return null
return noiseService.getPeerPublicKeyData(peerID)
?.takeIf { it.size == 32 }
?.copyOf()
return getAuthenticatedSession(peerID)?.remoteStaticKey?.copyOf()
}
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? =
noiseService.getAuthenticatedSession(peerID)
fun withAuthenticatedSession(
peerID: String,
expectedSession: AuthenticatedNoiseSession,
action: () -> Boolean
): Boolean = noiseService.withAuthenticatedSession(peerID, expectedSession, action)
/**
* Get current peer ID for a fingerprint (for peer ID rotation)
@@ -1,5 +1,6 @@
package com.bitchat.android.identity
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
@@ -7,6 +8,8 @@ import androidx.security.crypto.MasterKey
import java.security.MessageDigest
import android.util.Base64
import android.util.Log
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.util.hexEncodedString
import androidx.core.content.edit
@@ -33,6 +36,7 @@ class SecureIdentityStateManager {
private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints"
private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames"
private const val KEY_PRIVATE_MEDIA_CAPABILITY_PINS = "private_media_capability_pins_v1"
private const val KEY_AUTHENTICATED_PEER_STATES = "authenticated_peer_states_v1"
// BLE, Wi-Fi Aware, and Noise services each hold their own manager
// instance over the same encrypted preferences. Serialize pin updates
@@ -317,25 +321,6 @@ class SecureIdentityStateManager {
// MARK: - Authenticated private-media capability pins
/**
* Persist an HSTS-style private-media capability pin. The caller must
* derive [fingerprint] directly from a Noise-authenticated remote static
* key after matching it to a signature-verified announcement.
*/
fun markPrivateMediaCapable(fingerprint: String) {
if (!isValidFingerprint(fingerprint)) return
synchronized(privateMediaPinsLock) {
// A controller that survived panic must never be able to restore a
// pin from an in-flight pre-wipe handshake callback.
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return
val current = prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())
?.mapTo(mutableSetOf()) { it.lowercase() }
?: mutableSetOf()
current.add(fingerprint.lowercase())
prefs.edit { putStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, current) }
}
}
fun isPrivateMediaCapable(fingerprint: String): Boolean {
if (!isValidFingerprint(fingerprint)) return false
return synchronized(privateMediaPinsLock) {
@@ -347,13 +332,64 @@ class SecureIdentityStateManager {
}
}
internal fun getPrivateMediaCapabilityPinsForTesting(): Set<String> =
synchronized(privateMediaPinsLock) {
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) {
return@synchronized emptySet()
/** Persist capabilities and Ed25519 key from a decoded Noise 0x21 proof in one edit. */
@SuppressLint("UseKtx")
fun storeAuthenticatedPeerState(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit = {}
): Boolean {
if (!isValidFingerprint(fingerprint) || state.signingPublicKey.size != 32) return false
val normalizedFingerprint = fingerprint.lowercase()
return synchronized(privateMediaPinsLock) {
// A controller that survived panic must not republish pre-wipe proof state.
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized false
val records = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet())
?.toMutableSet() ?: mutableSetOf()
records.removeAll { it.startsWith("$normalizedFingerprint:") }
val capabilitiesHex = java.lang.Long.toUnsignedString(state.capabilities.rawValue, 16)
records.add(
"$normalizedFingerprint:$capabilitiesHex:${state.signingPublicKey.hexEncodedString()}"
)
val editor = prefs.edit().putStringSet(KEY_AUTHENTICATED_PEER_STATES, records)
if (state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) {
val pins = prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())
?.mapTo(mutableSetOf()) { it.lowercase() } ?: mutableSetOf()
pins.add(normalizedFingerprint)
editor.putStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, pins)
}
// This result is a security boundary: do not publish the Ed key in memory unless the
// encrypted identity record and its HSTS pin were durably committed together.
editor.commit().also { committed ->
if (committed) onCommitted()
}
prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())?.toSet() ?: emptySet()
}
}
fun getAuthenticatedPeerState(fingerprint: String): AuthenticatedPeerState? {
if (!isValidFingerprint(fingerprint)) return null
return synchronized(privateMediaPinsLock) {
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized null
val prefix = "${fingerprint.lowercase()}:"
val record = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet())
?.firstOrNull { it.startsWith(prefix) } ?: return@synchronized null
val fields = record.split(':', limit = 3)
if (fields.size != 3) return@synchronized null
val capabilities = runCatching {
PeerCapabilities(java.lang.Long.parseUnsignedLong(fields[1], 16))
}.getOrNull() ?: return@synchronized null
val signingKeyHex = fields[2]
if (!signingKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return@synchronized null
val signingKey = runCatching {
signingKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}.getOrNull() ?: return@synchronized null
AuthenticatedPeerState(capabilities, signingKey)
}
}
fun getAuthenticatedSigningKey(fingerprint: String): ByteArray? =
getAuthenticatedPeerState(fingerprint)?.signingPublicKey?.copyOf()
// MARK: - Peer ID Rotation Management (removed)
// Android now derives peer ID from the persisted Noise identity fingerprint.
@@ -429,12 +465,15 @@ class SecureIdentityStateManager {
/**
* Clear all identity data (for panic mode)
*/
@SuppressLint("UseKtx")
fun clearIdentityData() {
try {
synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch += 1
privateMediaPinsEpochAtCreation = privateMediaPinsEpoch
prefs.edit().clear().apply()
if (!prefs.edit().clear().commit()) {
Log.e(TAG, "Identity preference wipe could not be committed")
}
}
Log.w(TAG, "All identity data cleared")
} catch (e: Exception) {
@@ -0,0 +1,235 @@
package com.bitchat.android.mesh
import android.content.Context
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoisePeerIdentity
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
internal interface AuthenticatedPeerStateStore {
fun load(fingerprint: String): AuthenticatedPeerState?
fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean
fun isPrivateMediaPinned(fingerprint: String): Boolean
}
internal class SecureAuthenticatedPeerStateStore(context: Context) : AuthenticatedPeerStateStore {
private val identityState = SecureIdentityStateManager(context.applicationContext)
override fun load(fingerprint: String): AuthenticatedPeerState? =
identityState.getAuthenticatedPeerState(fingerprint)
override fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean = identityState.storeAuthenticatedPeerState(fingerprint, state, onCommitted)
override fun isPrivateMediaPinned(fingerprint: String): Boolean =
identityState.isPrivateMediaCapable(fingerprint)
}
internal sealed interface AuthenticatedPeerStateStatus {
data object Missing : AuthenticatedPeerStateStatus
data object Awaiting : AuthenticatedPeerStateStatus
data object TimedOut : AuthenticatedPeerStateStatus
data class Proven(val state: AuthenticatedPeerState) : AuthenticatedPeerStateStatus
}
/** Fresh, generation-scoped authenticated peer-state exchange for Noise payload 0x21. */
internal class AuthenticatedPeerStateCoordinator(
private val scope: CoroutineScope,
private val authenticatedSessionProvider: (String) -> AuthenticatedNoiseSession?,
private val withAuthenticatedSession: (
String,
AuthenticatedNoiseSession,
() -> Boolean
) -> Boolean,
private val store: AuthenticatedPeerStateStore,
private val localStateProvider: () -> AuthenticatedPeerState,
private val applyAuthenticatedState: (String, ByteArray, AuthenticatedPeerState) -> Unit,
private val sendState: (String, AuthenticatedPeerState, AuthenticatedNoiseSession) -> Boolean,
private val onResolution: (String) -> Unit,
private val proofTimeoutMs: Long = 5_000L
) {
private data class SessionState(
val authenticatedSession: AuthenticatedNoiseSession,
val fingerprint: String,
var status: AuthenticatedPeerStateStatus,
var echoSent: Boolean,
var timeoutJob: Job? = null
)
private val lock = Any()
private val sessions = ConcurrentHashMap<String, SessionState>()
fun onSessionAuthenticated(
peerID: String,
authenticatedRemoteStatic: ByteArray,
authenticatedSessionToken: ByteArray
) {
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteStatic)) return
if (authenticatedSessionToken.size != 32 ||
authenticatedSessionToken.all { it == 0.toByte() }
) return
val authenticatedSession = AuthenticatedNoiseSession(
authenticatedRemoteStatic.copyOf(),
authenticatedSessionToken.copyOf()
)
ensureSession(peerID, authenticatedSession)
}
/** Install one watchdog/exchange for this exact live generation, without resetting it. */
private fun ensureSession(
peerID: String,
authenticatedSession: AuthenticatedNoiseSession
): SessionState? {
val authenticatedRemoteStatic = authenticatedSession.remoteStaticKey
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteStatic)) return null
if (authenticatedSession.sessionToken.size != 32 ||
authenticatedSession.sessionToken.all { it == 0.toByte() }
) return null
// Ignore a delayed callback or policy snapshot if a later generation is already active.
if (authenticatedSessionProvider(peerID) != authenticatedSession) return null
val session = SessionState(
authenticatedSession = authenticatedSession,
fingerprint = fingerprint(authenticatedRemoteStatic),
status = AuthenticatedPeerStateStatus.Awaiting,
echoSent = false
)
val installed = withAuthenticatedSession(peerID, authenticatedSession) {
synchronized(lock) {
val existing = sessions[peerID]
if (existing?.authenticatedSession == authenticatedSession) {
return@synchronized false
}
sessions.put(peerID, session)?.timeoutJob?.cancel()
true
}
}
if (!installed) return synchronized(lock) { sessions[peerID] }
// Emit for every authenticated generation/rekey. Failure does not relax the watchdog.
runCatching { sendState(peerID, localStateProvider(), authenticatedSession) }
val timeout = scope.launch {
delay(proofTimeoutMs)
val resolved = synchronized(lock) {
val current = sessions[peerID]
if (current !== session || current.status !is AuthenticatedPeerStateStatus.Awaiting) {
false
} else {
current.status = AuthenticatedPeerStateStatus.TimedOut
true
}
}
if (resolved) onResolution(peerID)
}
synchronized(lock) {
if (sessions[peerID] === session && session.status is AuthenticatedPeerStateStatus.Awaiting) {
session.timeoutJob = timeout
} else {
timeout.cancel()
}
}
return session
}
/** Accept the first valid proof for this generation; repeated equal proofs are idempotent. */
fun receive(
peerID: String,
state: AuthenticatedPeerState,
decryptedSession: AuthenticatedNoiseSession
): Boolean {
val remoteStatic = decryptedSession.remoteStaticKey
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, remoteStatic)) return false
if (decryptedSession.sessionToken.size != 32 ||
decryptedSession.sessionToken.all { it == 0.toByte() }
) return false
val currentFingerprint = fingerprint(remoteStatic)
var shouldEcho = false
var echoSession: AuthenticatedNoiseSession? = null
val accepted = withAuthenticatedSession(peerID, decryptedSession) {
synchronized(lock) {
val current = sessions[peerID] ?: return@synchronized false
if (current.fingerprint != currentFingerprint ||
current.authenticatedSession != decryptedSession
) return@synchronized false
val proven = current.status as? AuthenticatedPeerStateStatus.Proven
if (proven != null) return@synchronized proven.state == state
try {
// Persist before publishing the replacement Ed key in memory, so a restart
// cannot reopen copied-static first-announce poisoning. The Noise manager
// lease prevents this generation from being replaced during the transition.
if (!store.persist(currentFingerprint, state) {
// Publish while the persistence epoch lock is still held. A panic wipe
// can therefore happen before both operations or after both, never between.
applyAuthenticatedState(peerID, remoteStatic, state)
}
) return@synchronized false
current.timeoutJob?.cancel()
current.status = AuthenticatedPeerStateStatus.Proven(state)
if (!current.echoSent) {
current.echoSent = true
shouldEcho = true
echoSession = current.authenticatedSession
}
true
} catch (_: Exception) {
false
}
}
}
if (!accepted) return false
if (shouldEcho) {
val exactSession = echoSession ?: return false
runCatching { sendState(peerID, localStateProvider(), exactSession) }
}
onResolution(peerID)
return true
}
fun status(
peerID: String,
authenticatedSession: AuthenticatedNoiseSession
): AuthenticatedPeerStateStatus {
ensureSession(peerID, authenticatedSession)
val currentFingerprint = fingerprint(authenticatedSession.remoteStaticKey)
return synchronized(lock) {
sessions[peerID]?.takeIf {
it.fingerprint == currentFingerprint &&
it.authenticatedSession == authenticatedSession
}?.status
?: AuthenticatedPeerStateStatus.Missing
}
}
fun persistedSigningKeyFor(noisePublicKey: ByteArray): ByteArray? {
if (noisePublicKey.size != 32) return null
return store.load(fingerprint(noisePublicKey))?.signingPublicKey?.copyOf()
}
fun isPrivateMediaPinned(peerID: String): Boolean {
val authenticatedSession = authenticatedSessionProvider(peerID) ?: return false
return store.isPrivateMediaPinned(fingerprint(authenticatedSession.remoteStaticKey))
}
fun clear(peerID: String) {
synchronized(lock) { sessions.remove(peerID)?.timeoutJob?.cancel() }
}
private fun fingerprint(publicKey: ByteArray): String =
MessageDigest.getInstance("SHA-256")
.digest(publicKey)
.joinToString("") { "%02x".format(it) }
}
@@ -4,6 +4,8 @@ import android.content.Context
import android.util.Log
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.protocol.MessagePadding
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.model.IdentityAnnouncement
@@ -50,18 +52,53 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
private val peerManager = PeerManager()
private val fragmentManager = FragmentManager()
private val privateMediaSecurity = PrivateMediaSecurityController(
peerInfoProvider = peerManager::getPeerInfo,
authenticatedRemoteStaticProvider = encryptionService::getAuthenticatedRemoteStaticKey,
pinStore = SecurePrivateMediaCapabilityPinStore(context)
)
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
private val authenticatedPeerState by lazy {
AuthenticatedPeerStateCoordinator(
scope = serviceScope,
authenticatedSessionProvider = encryptionService::getAuthenticatedSession,
withAuthenticatedSession = encryptionService::withAuthenticatedSession,
store = authenticatedPeerStateStore,
localStateProvider = {
AuthenticatedPeerState(
PeerCapabilities.LOCAL_SUPPORTED,
requireNotNull(encryptionService.getSigningPublicKey())
)
},
applyAuthenticatedState = peerManager::applyAuthenticatedPeerState,
sendState = ::sendAuthenticatedPeerState,
onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) }
)
}
private val privateMediaSecurity by lazy { PrivateMediaSecurityController(
authenticatedSessionProvider = encryptionService::getAuthenticatedSession,
peerStateStatusProvider = authenticatedPeerState::status,
isPrivateMediaPinned = authenticatedPeerState::isPrivateMediaPinned
) }
private val privateMediaPreparer by lazy {
PrivateMediaTransferPreparer(
senderID = hexStringToByteArray(myPeerID),
ttl = MAX_TTL,
policyProvider = privateMediaSecurity::sendPolicy,
encrypt = { plaintext, peerID ->
runCatching { encryptionService.encrypt(plaintext, peerID) }.getOrNull()
encrypt = { plaintext, peerID, authenticatedSession ->
try {
PrivateMediaEncryptionResult.Success(
encryptionService.encryptForSession(
plaintext,
peerID,
authenticatedSession
)
)
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionGenerationChanged) {
PrivateMediaEncryptionResult.GenerationChanged
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotFound) {
PrivateMediaEncryptionResult.GenerationChanged
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotEstablished) {
PrivateMediaEncryptionResult.GenerationChanged
} catch (_: Exception) {
PrivateMediaEncryptionResult.Failed
}
},
finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict,
fragment = fragmentManager::createFragments
@@ -87,7 +124,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
var delegate: BluetoothMeshDelegate? = null
// Coroutines
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var announceJob: Job? = null
// Tracks whether this instance has been terminated via stopServices()
private var terminated = false
@@ -218,6 +254,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
delegate?.didUpdatePeerList(peerIDs)
}
override fun onPeerRemoved(peerID: String) {
authenticatedPeerState.clear(peerID)
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
// Remove from mesh graph topology to prevent routing through stale peers
try { com.bitchat.android.services.meshgraph.MeshGraphService.getInstance().removePeer(peerID) } catch (_: Exception) { }
@@ -237,9 +274,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun onKeyExchangeCompleted(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
authenticatedSessionToken: ByteArray,
directRelayAddress: String?,
ingressLinkID: String?
) {
authenticatedPeerState.onSessionAuthenticated(
peerID,
authenticatedRemoteStaticKey,
authenticatedSessionToken
)
// Send announcement and cached messages after key exchange
serviceScope.launch {
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
@@ -271,6 +314,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun getPeerInfo(peerID: String): PeerInfo? {
return peerManager.getPeerInfo(peerID)
}
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
}
// StoreForwardManager delegates
@@ -330,10 +376,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
)
}
override fun onVerifiedAnnouncementProcessed(peerID: String) {
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
}
// Packet operations
override fun sendPacket(packet: BitchatPacket) {
// Sign the packet before broadcasting
@@ -358,13 +400,19 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
return securityManager.encryptForPeer(data, recipientPeerID)
}
override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
override fun decryptFromPeer(
encryptedData: ByteArray,
senderPeerID: String
): com.bitchat.android.noise.NoiseDecryptionResult? {
return securityManager.decryptFromPeer(encryptedData, senderPeerID)
}
override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean {
return encryptionService.verifyEd25519Signature(signature, data, publicKey)
}
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
// Noise protocol operations
override fun hasNoiseSession(peerID: String): Boolean {
@@ -408,6 +456,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
null
}
}
override fun onAuthenticatedPeerStateReceived(
peerID: String,
state: AuthenticatedPeerState,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
) {
authenticatedPeerState.receive(peerID, state, authenticatedSession)
}
// Message operations
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
@@ -786,6 +842,32 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
/**
* Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels).
*/
private fun sendAuthenticatedPeerState(
peerID: String,
state: AuthenticatedPeerState,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
): Boolean {
val plaintext = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode()
val ciphertext = securityManager.encryptForPeer(
plaintext,
peerID,
authenticatedSession
) ?: return false
val packet = BitchatPacket(
version = if (ciphertext.size > 0xFFFF) 2u else 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = ciphertext,
ttl = MAX_TTL
)
val signed = signPacketBeforeBroadcast(packet)
if (signed.signature?.size != 64) return false
broadcastRoutedPacket(RoutedPacket(signed))
return true
}
fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) {
try {
Log.d(TAG, "📤 sendFileBroadcast: name=${file.fileName}, size=${file.fileSize}")
@@ -834,6 +916,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
Log.i(TAG, "Private media needs a Noise handshake; initiating without sending")
initiateNoiseHandshake(recipientPeerID)
}
PrivateMediaPreparation.AwaitingPeerState -> Unit
is PrivateMediaPreparation.Rejected ->
Log.w(TAG, "Private media blocked: ${prepared.reason}")
}
@@ -855,6 +938,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning)
PrivateMediaBuildOutcome.NeedsHandshake ->
PrivateMediaPreparation.NeedsHandshake
PrivateMediaBuildOutcome.AwaitingPeerState ->
PrivateMediaPreparation.AwaitingPeerState
is PrivateMediaBuildOutcome.Rejected ->
PrivateMediaPreparation.Rejected(outcome.reason)
is PrivateMediaBuildOutcome.Ready -> {
@@ -5,6 +5,8 @@ import android.util.Log
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
@@ -51,18 +53,52 @@ class MeshCore(
private val peerManager = PeerManager()
val fragmentManager = FragmentManager()
private val privateMediaSecurity = PrivateMediaSecurityController(
peerInfoProvider = peerManager::getPeerInfo,
authenticatedRemoteStaticProvider = encryptionService::getAuthenticatedRemoteStaticKey,
pinStore = SecurePrivateMediaCapabilityPinStore(context)
)
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
private val authenticatedPeerState by lazy {
AuthenticatedPeerStateCoordinator(
scope = scope,
authenticatedSessionProvider = encryptionService::getAuthenticatedSession,
withAuthenticatedSession = encryptionService::withAuthenticatedSession,
store = authenticatedPeerStateStore,
localStateProvider = {
AuthenticatedPeerState(
PeerCapabilities.LOCAL_SUPPORTED,
requireNotNull(encryptionService.getSigningPublicKey())
)
},
applyAuthenticatedState = peerManager::applyAuthenticatedPeerState,
sendState = ::sendAuthenticatedPeerState,
onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) }
)
}
private val privateMediaSecurity by lazy { PrivateMediaSecurityController(
authenticatedSessionProvider = encryptionService::getAuthenticatedSession,
peerStateStatusProvider = authenticatedPeerState::status,
isPrivateMediaPinned = authenticatedPeerState::isPrivateMediaPinned
) }
private val privateMediaPreparer by lazy {
PrivateMediaTransferPreparer(
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
ttl = maxTtl,
policyProvider = privateMediaSecurity::sendPolicy,
encrypt = { plaintext, peerID ->
runCatching { encryptionService.encrypt(plaintext, peerID) }.getOrNull()
encrypt = { plaintext, peerID, authenticatedSession ->
try {
PrivateMediaEncryptionResult.Success(
encryptionService.encryptForSession(
plaintext,
peerID,
authenticatedSession
)
)
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionGenerationChanged) {
PrivateMediaEncryptionResult.GenerationChanged
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotFound) {
PrivateMediaEncryptionResult.GenerationChanged
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotEstablished) {
PrivateMediaEncryptionResult.GenerationChanged
} catch (_: Exception) {
PrivateMediaEncryptionResult.Failed
}
},
finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict,
fragment = fragmentManager::createFragments
@@ -179,6 +215,7 @@ class MeshCore(
}
override fun onPeerRemoved(peerID: String) {
authenticatedPeerState.clear(peerID)
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
try { encryptionService.removePeer(peerID) } catch (_: Exception) { }
try { peerManager.refreshPeerList() } catch (_: Exception) { }
@@ -189,9 +226,15 @@ class MeshCore(
override fun onKeyExchangeCompleted(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
authenticatedSessionToken: ByteArray,
directRelayAddress: String?,
ingressLinkID: String?
) {
authenticatedPeerState.onSessionAuthenticated(
peerID,
authenticatedRemoteStaticKey,
authenticatedSessionToken
)
if (directRelayAddress != null && ingressLinkID != null) {
hooks.onDirectNoiseAuthenticated?.invoke(
peerID,
@@ -222,6 +265,9 @@ class MeshCore(
}
override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
}
storeForwardManager.delegate = object : StoreForwardManagerDelegate {
@@ -285,10 +331,6 @@ class MeshCore(
)
}
override fun onVerifiedAnnouncementProcessed(peerID: String) {
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
}
override fun sendPacket(packet: BitchatPacket) {
val signedPacket = signPacketBeforeBroadcast(packet)
dispatchGlobal(RoutedPacket(signedPacket))
@@ -310,7 +352,10 @@ class MeshCore(
return securityManager.encryptForPeer(data, recipientPeerID)
}
override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
override fun decryptFromPeer(
encryptedData: ByteArray,
senderPeerID: String
): com.bitchat.android.noise.NoiseDecryptionResult? {
return securityManager.decryptFromPeer(encryptedData, senderPeerID)
}
@@ -318,6 +363,9 @@ class MeshCore(
return encryptionService.verifyEd25519Signature(signature, data, publicKey)
}
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
override fun hasNoiseSession(peerID: String): Boolean {
return encryptionService.hasEstablishedSession(peerID)
}
@@ -334,6 +382,14 @@ class MeshCore(
}
}
override fun onAuthenticatedPeerStateReceived(
peerID: String,
state: AuthenticatedPeerState,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
) {
authenticatedPeerState.receive(peerID, state, authenticatedSession)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return delegate?.decryptChannelMessage(encryptedContent, channel)
}
@@ -471,6 +527,32 @@ class MeshCore(
}
}
private fun sendAuthenticatedPeerState(
peerID: String,
state: AuthenticatedPeerState,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
): Boolean {
val plaintext = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode()
val ciphertext = securityManager.encryptForPeer(
plaintext,
peerID,
authenticatedSession
) ?: return false
val packet = BitchatPacket(
version = if (ciphertext.size > 0xFFFF) 2u else 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = ciphertext,
ttl = maxTtl
)
val signed = signPacketBeforeBroadcast(packet)
if (signed.signature?.size != 64) return false
dispatchGlobal(RoutedPacket(signed))
return true
}
fun sendFileBroadcast(file: BitchatFilePacket) {
try {
val payload = file.encode() ?: return
@@ -510,6 +592,7 @@ class MeshCore(
Log.i("MeshCore", "Private media needs a Noise handshake; initiating without sending")
initiateNoiseHandshake(recipientPeerID)
}
PrivateMediaPreparation.AwaitingPeerState -> Unit
is PrivateMediaPreparation.Rejected ->
Log.w("MeshCore", "Private media blocked: ${prepared.reason}")
}
@@ -531,6 +614,8 @@ class MeshCore(
PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning)
PrivateMediaBuildOutcome.NeedsHandshake ->
PrivateMediaPreparation.NeedsHandshake
PrivateMediaBuildOutcome.AwaitingPeerState ->
PrivateMediaPreparation.AwaitingPeerState
is PrivateMediaBuildOutcome.Rejected ->
PrivateMediaPreparation.Rejected(outcome.reason)
is PrivateMediaBuildOutcome.Ready -> {
@@ -13,6 +13,8 @@ interface MeshDelegate {
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {}
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {}
/** Current Noise generation either proved peer state or exhausted its 5-second watchdog. */
fun didResolvePrivateMediaPolicy(peerID: String) {}
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
fun getNickname(): String?
fun isFavorite(peerID: String): Boolean
@@ -3,6 +3,7 @@ package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
@@ -59,11 +60,12 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
try {
// Decrypt the message using the Noise service
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
if (decryptedData == null) {
val decryption = delegate?.decryptFromPeer(packet.payload, peerID)
if (decryption == null) {
Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake")
return
}
val decryptedData = decryption.plaintext
if (decryptedData.isEmpty()) {
Log.w(TAG, "Decrypted data is empty from $peerID")
@@ -145,6 +147,19 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
Log.w(TAG, "⚠️ Failed to decode encrypted file transfer from $peerID")
}
}
com.bitchat.android.model.NoisePayloadType.PEER_STATE -> {
val authenticatedState = AuthenticatedPeerState.decode(noisePayload.data)
if (authenticatedState == null) {
Log.w(TAG, "Dropping malformed authenticated peer state from ${peerID.take(8)}")
} else {
delegate?.onAuthenticatedPeerStateReceived(
peerID,
authenticatedState,
decryption.authenticatedSession
)
}
}
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
// Handle delivery ACK exactly like iOS
@@ -248,6 +263,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
return AnnounceHandlingResult.Rejected
}
val persistedSigningKey = delegate?.getAuthenticatedSigningKey(announcement.noisePublicKey)
if (persistedSigningKey != null &&
!persistedSigningKey.contentEquals(announcement.signingPublicKey)
) {
Log.w(TAG, "Rejecting ANNOUNCE Ed key that conflicts with authenticated peer state")
return AnnounceHandlingResult.Rejected
}
var verified = true
// Check for existing peer with different noise public key
@@ -629,7 +652,6 @@ interface MessageHandlerDelegate {
isVerified: Boolean,
capabilities: com.bitchat.android.model.PeerCapabilities? = null
): Boolean
fun onVerifiedAnnouncementProcessed(peerID: String) {}
// Packet operations
fun sendPacket(packet: BitchatPacket)
@@ -639,13 +661,22 @@ interface MessageHandlerDelegate {
// Cryptographic operations
fun verifySignature(packet: BitchatPacket, peerID: String): Boolean
fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray?
fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray?
fun decryptFromPeer(
encryptedData: ByteArray,
senderPeerID: String
): com.bitchat.android.noise.NoiseDecryptionResult?
fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean
fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null
// Noise protocol operations
fun hasNoiseSession(peerID: String): Boolean
fun initiateNoiseHandshake(peerID: String)
fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray?
fun onAuthenticatedPeerStateReceived(
peerID: String,
state: AuthenticatedPeerState,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
) {}
// Message operations
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
@@ -1,6 +1,7 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
@@ -162,6 +163,37 @@ class PeerManager {
verifiedAnnouncementNoisePublicKey = noisePublicKey.copyOf()
)
/** Replace capability/Ed identity from Noise 0x21 in one peer-map mutation. */
@Synchronized
fun applyAuthenticatedPeerState(
peerID: String,
authenticatedNoisePublicKey: ByteArray,
state: AuthenticatedPeerState
) {
val existing = peers[peerID]
val announcementMatchesAuthenticatedState = existing?.hasVerifiedAnnouncement == true &&
existing.verifiedAnnouncementNoisePublicKey?.contentEquals(authenticatedNoisePublicKey) == true &&
existing.signingPublicKey?.contentEquals(state.signingPublicKey) == true
val replacement = PeerInfo(
id = peerID,
// A copied-static preannouncement cannot retain its attacker-chosen display name once
// authenticated peer state proves a different Ed key.
nickname = existing?.nickname?.takeIf { announcementMatchesAuthenticatedState } ?: peerID,
isConnected = true,
isDirectConnection = existing?.isDirectConnection ?: false,
noisePublicKey = authenticatedNoisePublicKey.copyOf(),
signingPublicKey = state.signingPublicKey.copyOf(),
isVerifiedNickname = existing?.isVerifiedNickname == true && announcementMatchesAuthenticatedState,
lastSeen = System.currentTimeMillis(),
capabilities = state.capabilities,
hasVerifiedAnnouncement = announcementMatchesAuthenticatedState,
verifiedAnnouncementNoisePublicKey = authenticatedNoisePublicKey.copyOf()
.takeIf { announcementMatchesAuthenticatedState }
)
peers[peerID] = replacement
if (existing == null || existing != replacement) notifyPeerListUpdate()
}
private fun updatePeerInfoInternal(
peerID: String,
nickname: String,
@@ -1,31 +1,15 @@
package com.bitchat.android.mesh
import android.content.Context
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.model.PeerCapabilities
import java.security.MessageDigest
internal interface PrivateMediaCapabilityPinStore {
fun contains(fingerprint: String): Boolean
fun insert(fingerprint: String)
}
internal class SecurePrivateMediaCapabilityPinStore(context: Context) :
PrivateMediaCapabilityPinStore {
private val identityState = SecureIdentityStateManager(context.applicationContext)
override fun contains(fingerprint: String): Boolean =
identityState.isPrivateMediaCapable(fingerprint)
override fun insert(fingerprint: String) {
identityState.markPrivateMediaCapable(fingerprint)
}
}
import com.bitchat.android.noise.AuthenticatedNoiseSession
internal sealed interface PrivateMediaPolicyDecision {
data object Encrypted : PrivateMediaPolicyDecision
data class Encrypted(
val authenticatedSession: AuthenticatedNoiseSession
) : PrivateMediaPolicyDecision
data object RequiresLegacyConsent : PrivateMediaPolicyDecision
data object NeedsHandshake : PrivateMediaPolicyDecision
data object AwaitingPeerState : PrivateMediaPolicyDecision
data class Blocked(val reason: String) : PrivateMediaPolicyDecision
}
@@ -35,71 +19,46 @@ internal sealed interface PrivateMediaPolicyDecision {
* fingerprint. Announcements alone can never create a pin.
*/
internal class PrivateMediaSecurityController(
private val peerInfoProvider: (String) -> PeerInfo?,
private val authenticatedRemoteStaticProvider: (String) -> ByteArray?,
private val pinStore: PrivateMediaCapabilityPinStore
private val authenticatedSessionProvider: (String) -> AuthenticatedNoiseSession?,
private val peerStateStatusProvider: (
String,
AuthenticatedNoiseSession
) -> AuthenticatedPeerStateStatus,
private val isPrivateMediaPinned: (String) -> Boolean
) {
fun refreshAuthenticatedCapability(peerID: String): Boolean {
val remoteStatic = authenticatedRemoteStaticProvider(peerID)
?.takeIf { it.size == 32 }
?: return false
val peer = peerInfoProvider(peerID) ?: return false
if (!peer.hasVerifiedAnnouncement) return false
val announcedStatic = peer.verifiedAnnouncementNoisePublicKey ?: return false
if (!announcedStatic.contentEquals(remoteStatic)) return false
if (peer.capabilities?.contains(PeerCapabilities.PRIVATE_MEDIA) != true) return false
pinStore.insert(fingerprint(remoteStatic))
return true
}
fun sendPolicy(peerID: String): PrivateMediaPolicyDecision {
val remoteStatic = authenticatedRemoteStaticProvider(peerID)
?.takeIf { it.size == 32 }
val authenticatedSession = authenticatedSessionProvider(peerID)
?.takeIf { it.remoteStaticKey.size == 32 && it.sessionToken.size == 32 }
?: return PrivateMediaPolicyDecision.NeedsHandshake
val authenticatedFingerprint = fingerprint(remoteStatic)
// Once a fingerprint has authenticated encrypted media support, never
// downgrade it because a later announce omits or clears the bit.
if (pinStore.contains(authenticatedFingerprint)) {
return PrivateMediaPolicyDecision.Encrypted
}
val peer = peerInfoProvider(peerID)
?: return PrivateMediaPolicyDecision.Blocked(
"No signature-verified identity announcement is available"
)
if (!peer.hasVerifiedAnnouncement) {
return PrivateMediaPolicyDecision.Blocked(
"The peer identity announcement has not been verified"
)
}
val announcedStatic = peer.verifiedAnnouncementNoisePublicKey
?: return PrivateMediaPolicyDecision.Blocked(
"The verified announcement did not contain a Noise identity"
)
if (!announcedStatic.contentEquals(remoteStatic)) {
return PrivateMediaPolicyDecision.Blocked(
"The authenticated Noise identity does not match the verified announcement"
)
}
return if (peer.capabilities?.contains(PeerCapabilities.PRIVATE_MEDIA) == true) {
pinStore.insert(authenticatedFingerprint)
PrivateMediaPolicyDecision.Encrypted
} else {
// Both a missing TLV and an explicitly empty bitfield are legacy.
PrivateMediaPolicyDecision.RequiresLegacyConsent
return when (val status = peerStateStatusProvider(peerID, authenticatedSession)) {
AuthenticatedPeerStateStatus.Awaiting -> PrivateMediaPolicyDecision.AwaitingPeerState
is AuthenticatedPeerStateStatus.Proven -> {
if (status.state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) {
PrivateMediaPolicyDecision.Encrypted(authenticatedSession)
} else if (isPrivateMediaPinned(peerID)) {
PrivateMediaPolicyDecision.Blocked(
"Encrypted private media was previously pinned, but this session proved no support; send blocked"
)
} else {
PrivateMediaPolicyDecision.RequiresLegacyConsent
}
}
AuthenticatedPeerStateStatus.Missing ->
// A live crypto session can become visible just before its authenticated callback
// installs the coordinator generation. Never treat that gap as a watchdog timeout.
PrivateMediaPolicyDecision.AwaitingPeerState
AuthenticatedPeerStateStatus.TimedOut -> {
if (isPrivateMediaPinned(peerID)) {
PrivateMediaPolicyDecision.Blocked(
"Encrypted private media was previously pinned, but this session did not provide authenticated peer state"
)
} else {
// A Noise-capable old client that does not know 0x21 may use explicit one-shot
// legacy consent after the five-second generation watchdog.
PrivateMediaPolicyDecision.RequiresLegacyConsent
}
}
}
}
internal fun authenticatedFingerprint(peerID: String): String? =
authenticatedRemoteStaticProvider(peerID)
?.takeIf { it.size == 32 }
?.let(::fingerprint)
private fun fingerprint(publicKey: ByteArray): String =
MessageDigest.getInstance("SHA-256")
.digest(publicKey)
.joinToString("") { "%02x".format(it) }
}
@@ -3,6 +3,7 @@ package com.bitchat.android.mesh
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import java.util.concurrent.atomic.AtomicBoolean
@@ -30,6 +31,7 @@ sealed interface PrivateMediaPreparation {
data class Ready(val transfer: PreparedPrivateMediaTransfer) : PrivateMediaPreparation
data class RequiresLegacyConsent(val warning: String) : PrivateMediaPreparation
data object NeedsHandshake : PrivateMediaPreparation
data object AwaitingPeerState : PrivateMediaPreparation
data class Rejected(val reason: String) : PrivateMediaPreparation
}
@@ -43,15 +45,26 @@ internal sealed interface PrivateMediaBuildOutcome {
data class Ready(val built: BuiltPrivateMediaTransfer) : PrivateMediaBuildOutcome
data class RequiresLegacyConsent(val warning: String) : PrivateMediaBuildOutcome
data object NeedsHandshake : PrivateMediaBuildOutcome
data object AwaitingPeerState : PrivateMediaBuildOutcome
data class Rejected(val reason: String) : PrivateMediaBuildOutcome
}
internal sealed interface PrivateMediaEncryptionResult {
data class Success(val ciphertext: ByteArray) : PrivateMediaEncryptionResult
data object GenerationChanged : PrivateMediaEncryptionResult
data object Failed : PrivateMediaEncryptionResult
}
/** Builds, routes, signs, and fragments exactly once before UI local echo. */
internal class PrivateMediaTransferPreparer(
private val senderID: ByteArray,
private val ttl: UByte,
private val policyProvider: (String) -> PrivateMediaPolicyDecision,
private val encrypt: (ByteArray, String) -> ByteArray?,
private val encrypt: (
ByteArray,
String,
AuthenticatedNoiseSession
) -> PrivateMediaEncryptionResult,
private val finalizeRoutedAndSigned: (BitchatPacket) -> BitchatPacket?,
private val fragment: (BitchatPacket, Int) -> List<BitchatPacket>,
private val now: () -> ULong = { System.currentTimeMillis().toULong() }
@@ -61,10 +74,24 @@ internal class PrivateMediaTransferPreparer(
recipientID: ByteArray,
file: BitchatFilePacket,
allowLegacyFallback: Boolean
): PrivateMediaBuildOutcome = prepare(
recipientPeerID,
recipientID,
file,
allowLegacyFallback,
generationRetriesRemaining = 1
)
private fun prepare(
recipientPeerID: String,
recipientID: ByteArray,
file: BitchatFilePacket,
allowLegacyFallback: Boolean,
generationRetriesRemaining: Int
): PrivateMediaBuildOutcome {
val policy = policyProvider(recipientPeerID)
val mode = when (policy) {
PrivateMediaPolicyDecision.Encrypted -> PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
is PrivateMediaPolicyDecision.Encrypted -> PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
PrivateMediaPolicyDecision.RequiresLegacyConsent -> {
if (!allowLegacyFallback) {
return PrivateMediaBuildOutcome.RequiresLegacyConsent(
@@ -77,6 +104,8 @@ internal class PrivateMediaTransferPreparer(
}
PrivateMediaPolicyDecision.NeedsHandshake ->
return PrivateMediaBuildOutcome.NeedsHandshake
PrivateMediaPolicyDecision.AwaitingPeerState ->
return PrivateMediaBuildOutcome.AwaitingPeerState
is PrivateMediaPolicyDecision.Blocked ->
return PrivateMediaBuildOutcome.Rejected(policy.reason)
}
@@ -87,13 +116,34 @@ internal class PrivateMediaTransferPreparer(
val packet = when (mode) {
PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 -> {
val plaintext = NoisePayload(NoisePayloadType.FILE_TRANSFER, filePayload).encode()
val ciphertext = try {
encrypt(plaintext, recipientPeerID)
val encryption = try {
encrypt(
plaintext,
recipientPeerID,
(policy as PrivateMediaPolicyDecision.Encrypted).authenticatedSession
)
} catch (_: Exception) {
null
} ?: return PrivateMediaBuildOutcome.Rejected(
"The authenticated Noise session could not encrypt this file"
)
PrivateMediaEncryptionResult.Failed
}
val ciphertext = when (encryption) {
is PrivateMediaEncryptionResult.Success -> encryption.ciphertext
PrivateMediaEncryptionResult.GenerationChanged -> {
if (generationRetriesRemaining > 0) {
return prepare(
recipientPeerID,
recipientID,
file,
allowLegacyFallback,
generationRetriesRemaining - 1
)
}
return PrivateMediaBuildOutcome.AwaitingPeerState
}
PrivateMediaEncryptionResult.Failed ->
return PrivateMediaBuildOutcome.Rejected(
"The authenticated Noise session could not encrypt this file"
)
}
BitchatPacket(
version = if (ciphertext.size > 0xFFFF) 2u else 1u,
type = MessageType.NOISE_ENCRYPTED.value,
@@ -5,6 +5,8 @@ import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoiseDecryptionResult
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import java.util.*
@@ -136,11 +138,19 @@ class SecurityManager(private val encryptionService: EncryptionService, private
Log.e(TAG, "Bound Noise completion for $peerID omitted its authenticated static key")
return false
}
val authenticatedSessionToken = result.authenticatedSessionToken
if (authenticatedSessionToken?.size != 32 ||
authenticatedSessionToken.all { it == 0.toByte() }
) {
Log.e(TAG, "Bound Noise completion for $peerID omitted its generation token")
return false
}
val isDirectIngress = packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
Log.d(TAG, "✅ Noise handshake completed with $peerID")
delegate?.onKeyExchangeCompleted(
peerID = peerID,
authenticatedRemoteStaticKey = authenticatedRemoteStaticKey,
authenticatedSessionToken = authenticatedSessionToken,
directRelayAddress = routed.relayAddress.takeIf { isDirectIngress },
ingressLinkID = routed.ingressLinkID.takeIf { isDirectIngress }
)
@@ -187,13 +197,24 @@ class SecurityManager(private val encryptionService: EncryptionService, private
null
}
}
fun encryptForPeer(
data: ByteArray,
recipientPeerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray? = try {
encryptionService.encryptForSession(data, recipientPeerID, expectedSession)
} catch (e: Exception) {
Log.e(TAG, "Noise generation changed before encrypting for $recipientPeerID: ${e.message}")
null
}
/**
* Decrypt payload from specific peer
*/
fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): NoiseDecryptionResult? {
return try {
encryptionService.decrypt(encryptedData, senderPeerID)
encryptionService.decryptWithSession(encryptedData, senderPeerID)
} catch (e: Exception) {
Log.e(TAG, "Failed to decrypt from $senderPeerID: ${e.message}")
null
@@ -250,6 +271,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private
return false
}
val persistedSigningKey = delegate?.getAuthenticatedSigningKey(announcement.noisePublicKey)
if (persistedSigningKey != null &&
!persistedSigningKey.contentEquals(announcement.signingPublicKey)
) {
Log.w(TAG, "Rejecting ANNOUNCE Ed key that conflicts with authenticated peer state for $peerID")
return false
}
val existingPeer = delegate?.getPeerInfo(peerID)
if (
existingPeer?.noisePublicKey != null &&
@@ -429,9 +458,11 @@ interface SecurityManagerDelegate {
fun onKeyExchangeCompleted(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
authenticatedSessionToken: ByteArray,
directRelayAddress: String?,
ingressLinkID: String?
)
fun sendHandshakeResponse(peerID: String, response: ByteArray)
fun getPeerInfo(peerID: String): PeerInfo? // NEW: For signature verification
fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null
}
@@ -358,6 +358,10 @@ class UnifiedMeshService(
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
}
override fun didResolvePrivateMediaPolicy(peerID: String) {
delegate?.didResolvePrivateMediaPolicy(peerID)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return delegate?.decryptChannelMessage(encryptedContent, channel)
}
@@ -0,0 +1,86 @@
package com.bitchat.android.model
/**
* Canonical payload carried inside Noise payload type 0x21.
*
* Wire format:
* `[version=0x01][type=0x01][len=1...8][minimal LE capabilities]`
* `[type=0x02][len=32][Ed25519 public key]`
*
* Unknown TLVs are skipped. Both known fields must occur exactly once.
*/
data class AuthenticatedPeerState(
val capabilities: PeerCapabilities,
val signingPublicKey: ByteArray
) {
init {
require(signingPublicKey.size == SIGNING_PUBLIC_KEY_SIZE) {
"Ed25519 public key must be 32 bytes"
}
}
fun encode(): ByteArray {
val capabilityBytes = capabilities.encoded()
return buildList<Byte>(1 + 2 + capabilityBytes.size + 2 + signingPublicKey.size) {
add(VERSION.toByte())
add(CAPABILITIES_TLV.toByte())
add(capabilityBytes.size.toByte())
addAll(capabilityBytes.toList())
add(SIGNING_PUBLIC_KEY_TLV.toByte())
add(SIGNING_PUBLIC_KEY_SIZE.toByte())
addAll(signingPublicKey.toList())
}.toByteArray()
}
companion object {
const val VERSION = 0x01
private const val CAPABILITIES_TLV = 0x01
private const val SIGNING_PUBLIC_KEY_TLV = 0x02
private const val SIGNING_PUBLIC_KEY_SIZE = 32
fun decode(data: ByteArray): AuthenticatedPeerState? {
if (data.firstOrNull()?.toInt()?.and(0xFF) != VERSION) return null
var offset = 1
var capabilities: PeerCapabilities? = null
var signingPublicKey: ByteArray? = null
while (offset < data.size) {
if (offset + 2 > data.size) return null
val type = data[offset].toInt() and 0xFF
val length = data[offset + 1].toInt() and 0xFF
offset += 2
if (offset + length > data.size) return null
val value = data.copyOfRange(offset, offset + length)
offset += length
when (type) {
CAPABILITIES_TLV -> {
if (capabilities != null || length !in 1..8) return null
val decoded = PeerCapabilities.decode(value)
if (!decoded.encoded().contentEquals(value)) return null
capabilities = decoded
}
SIGNING_PUBLIC_KEY_TLV -> {
if (signingPublicKey != null || length != SIGNING_PUBLIC_KEY_SIZE) return null
signingPublicKey = value
}
else -> Unit
}
}
val decodedCapabilities = capabilities ?: return null
val decodedSigningKey = signingPublicKey ?: return null
return AuthenticatedPeerState(decodedCapabilities, decodedSigningKey)
}
}
override fun equals(other: Any?): Boolean =
this === other ||
(other is AuthenticatedPeerState &&
capabilities == other.capabilities &&
signingPublicKey.contentEquals(other.signingPublicKey))
override fun hashCode(): Int = 31 * capabilities.hashCode() + signingPublicKey.contentHashCode()
}
@@ -23,7 +23,9 @@ enum class NoisePayloadType(val value: UByte) {
DELIVERED(0x03u), // Message was delivered
VERIFY_CHALLENGE(0x10u), // Verification challenge
VERIFY_RESPONSE(0x11u), // Verification response
FILE_TRANSFER(0x20u);
FILE_TRANSFER(0x20u),
/** Authenticated capabilities + Ed25519 binding for the current Noise generation. */
PEER_STATE(0x21u);
companion object {
@@ -149,6 +149,15 @@ class NoiseEncryptionService(private val context: Context) {
fun getPeerPublicKeyData(peerID: String): ByteArray? {
return sessionManager.getRemoteStaticKey(peerID)
}
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? =
sessionManager.getAuthenticatedSession(peerID)
fun withAuthenticatedSession(
peerID: String,
expectedSession: AuthenticatedNoiseSession,
action: () -> Boolean
): Boolean = sessionManager.withAuthenticatedSession(peerID, expectedSession, action)
/**
* Clear persistent identity (for panic mode)
@@ -247,6 +256,13 @@ class NoiseEncryptionService(private val context: Context) {
null
}
}
@Throws(Exception::class)
fun encryptForSession(
data: ByteArray,
peerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray = sessionManager.encryptForSession(data, peerID, expectedSession)
/**
* Decrypt data from a specific peer using established Noise session
@@ -264,6 +280,14 @@ class NoiseEncryptionService(private val context: Context) {
null
}
}
fun decryptWithSession(encryptedData: ByteArray, peerID: String): NoiseDecryptionResult? =
try {
sessionManager.decryptWithSession(encryptedData, peerID)
} catch (e: Exception) {
Log.e(TAG, "Failed generation-bound decryption from $peerID: ${e.message}")
null
}
// MARK: - Peer Management
@@ -474,7 +474,10 @@ class NoiseSession(
receiveCipher = cipherPair.getReceiver()
// Extract handshake hash for channel binding
handshakeHash = activeHandshake.getHandshakeHash()
// getHandshakeHash() exposes the handshake state's backing array. Clone it before
// destroy() zeroizes that state, or every completed session appears to have the same
// all-zero channel-binding token.
handshakeHash = activeHandshake.getHandshakeHash().clone()
// Clean up handshake state
activeHandshake.destroy()
@@ -7,7 +7,36 @@ data class NoiseHandshakeProcessingResult(
val response: ByteArray?,
val establishedNow: Boolean,
/** The bound remote static key only when this exact call completed authentication. */
val authenticatedRemoteStaticKey: ByteArray? = null
val authenticatedRemoteStaticKey: ByteArray? = null,
/** Handshake hash identifying that exact authenticated Noise generation. */
val authenticatedSessionToken: ByteArray? = null
)
/** Atomic snapshot of the live authenticated Noise generation. */
class AuthenticatedNoiseSession(
remoteStaticKey: ByteArray,
sessionToken: ByteArray
) {
private val remoteStaticKeyBytes = remoteStaticKey.copyOf()
private val sessionTokenBytes = sessionToken.copyOf()
val remoteStaticKey: ByteArray get() = remoteStaticKeyBytes.copyOf()
val sessionToken: ByteArray get() = sessionTokenBytes.copyOf()
override fun equals(other: Any?): Boolean =
this === other ||
(other is AuthenticatedNoiseSession &&
remoteStaticKeyBytes.contentEquals(other.remoteStaticKeyBytes) &&
sessionTokenBytes.contentEquals(other.sessionTokenBytes))
override fun hashCode(): Int =
31 * remoteStaticKeyBytes.contentHashCode() + sessionTokenBytes.contentHashCode()
}
/** Plaintext and generation binding captured atomically from the session that decrypted it. */
data class NoiseDecryptionResult(
val plaintext: ByteArray,
val authenticatedSession: AuthenticatedNoiseSession
)
/**
@@ -23,6 +52,7 @@ class NoiseSessionManager(
private const val TAG = "NoiseSessionManager"
private const val HANDSHAKE_TIMEOUT_MS = 20_000L
private const val HANDSHAKE_MESSAGE_1_SIZE = 32
private const val SESSION_TOKEN_SIZE = 32
}
private val sessions = ConcurrentHashMap<String, NoiseSession>()
@@ -130,6 +160,7 @@ class NoiseSessionManager(
var activeSession: NoiseSession? = null
var isReplacementCandidate = false
var establishedRemoteKey: ByteArray? = null
var establishedSessionToken: ByteArray? = null
var response: ByteArray? = null
try {
@@ -204,6 +235,10 @@ class NoiseSessionManager(
throw NoiseSessionError.PeerIdentityMismatch(peerID, derivedPeerID)
}
val sessionToken = session.getHandshakeHash()
?.takeIf { it.size == SESSION_TOKEN_SIZE && it.any { byte -> byte != 0.toByte() } }
?: throw NoiseSessionError.HandshakeFailed
if (isReplacementCandidate) {
responderCandidates.remove(peerID, session)
val previous = sessions.put(peerID, session)
@@ -211,6 +246,7 @@ class NoiseSessionManager(
}
establishedRemoteKey = remoteStaticKey
establishedSessionToken = sessionToken
Log.d(TAG, "✅ Session ESTABLISHED with bound identity $peerID")
}
} catch (e: Exception) {
@@ -232,7 +268,8 @@ class NoiseSessionManager(
return NoiseHandshakeProcessingResult(
response = response,
establishedNow = establishedRemoteKey != null,
authenticatedRemoteStaticKey = establishedRemoteKey?.clone()
authenticatedRemoteStaticKey = establishedRemoteKey?.clone(),
authenticatedSessionToken = establishedSessionToken?.clone()
)
}
@@ -252,6 +289,7 @@ class NoiseSessionManager(
/**
* SIMPLIFIED: Encrypt data
*/
@Synchronized
fun encrypt(data: ByteArray, peerID: String): ByteArray {
val session = getSession(peerID) ?: throw IllegalStateException("No session found for $peerID")
if (!session.isEstablished()) {
@@ -259,11 +297,29 @@ class NoiseSessionManager(
}
return session.encrypt(data)
}
/** Encrypt only if the exact generation that authorized the operation is still active. */
@Synchronized
fun encryptForSession(
data: ByteArray,
peerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray {
val session = getSession(peerID) ?: throw NoiseSessionError.SessionNotFound
if (!session.isEstablished()) throw NoiseSessionError.SessionNotEstablished
val current = authenticatedSession(session) ?: throw NoiseSessionError.SessionNotEstablished
if (current != expectedSession) throw NoiseSessionError.SessionGenerationChanged
return session.encrypt(data)
}
/**
* SIMPLIFIED: Decrypt data
*/
fun decrypt(encryptedData: ByteArray, peerID: String): ByteArray {
fun decrypt(encryptedData: ByteArray, peerID: String): ByteArray =
decryptWithSession(encryptedData, peerID).plaintext
@Synchronized
fun decryptWithSession(encryptedData: ByteArray, peerID: String): NoiseDecryptionResult {
val session = getSession(peerID)
if (session == null) {
Log.e(TAG, "No session found for $peerID when trying to decrypt")
@@ -273,7 +329,10 @@ class NoiseSessionManager(
Log.e(TAG, "Session not established with $peerID when trying to decrypt")
throw IllegalStateException("Session not established with $peerID")
}
return session.decrypt(encryptedData)
val plaintext = session.decrypt(encryptedData)
val authenticatedSession = authenticatedSession(session)
?: throw IllegalStateException("Established session for $peerID has no channel binding")
return NoiseDecryptionResult(plaintext, authenticatedSession)
}
/**
@@ -295,15 +354,42 @@ class NoiseSessionManager(
/**
* Get remote static public key for a peer (if session established)
*/
fun getRemoteStaticKey(peerID: String): ByteArray? {
return getSession(peerID)?.getRemoteStaticPublicKey()
fun getRemoteStaticKey(peerID: String): ByteArray? =
getAuthenticatedSession(peerID)?.remoteStaticKey
@Synchronized
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? {
val session = getSession(peerID) ?: return null
if (!session.isEstablished()) return null
return authenticatedSession(session)
}
/** Execute a state transition while preventing replacement/removal of the expected session. */
@Synchronized
fun withAuthenticatedSession(
peerID: String,
expectedSession: AuthenticatedNoiseSession,
action: () -> Boolean
): Boolean {
val session = getSession(peerID) ?: return false
if (!session.isEstablished()) return false
if (authenticatedSession(session) != expectedSession) return false
return action()
}
/**
* Get handshake hash for channel binding (if session established)
*/
fun getHandshakeHash(peerID: String): ByteArray? {
return getSession(peerID)?.getHandshakeHash()
return getAuthenticatedSession(peerID)?.sessionToken
}
private fun authenticatedSession(session: NoiseSession): AuthenticatedNoiseSession? {
val remoteStaticKey = session.getRemoteStaticPublicKey()?.takeIf { it.size == 32 } ?: return null
val sessionToken = session.getHandshakeHash()?.takeIf {
it.size == SESSION_TOKEN_SIZE && it.any { byte -> byte != 0.toByte() }
} ?: return null
return AuthenticatedNoiseSession(remoteStaticKey, sessionToken)
}
/**
@@ -356,6 +442,7 @@ sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exce
object InvalidState : NoiseSessionError("Session in invalid state")
object HandshakeFailed : NoiseSessionError("Handshake failed")
object AlreadyEstablished : NoiseSessionError("Session already established")
object SessionGenerationChanged : NoiseSessionError("Noise session generation changed")
class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError(
"Authenticated Noise key derives to ${derivedPeerID ?: "invalid"}, not claimed peer $claimedPeerID"
)
@@ -191,7 +191,8 @@ class NostrDirectMessageHandler(
}
}
NoisePayloadType.VERIFY_CHALLENGE,
NoisePayloadType.VERIFY_RESPONSE -> Unit // Ignore verification payloads in Nostr direct messages
NoisePayloadType.VERIFY_RESPONSE,
NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation.
}
}
@@ -131,7 +131,12 @@ class ChatViewModel(
val verifiedFingerprints = verificationHandler.verifiedFingerprints
// Media file sending manager
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { mesh }
private val mediaSendingManager = MediaSendingManager(
state,
messageManager,
channelManager,
viewModelScope
) { mesh }
// Delegate handler for mesh callbacks
private val meshDelegateHandler = MeshDelegateHandler(
@@ -931,6 +936,10 @@ class ChatViewModel(
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
verificationHandler.didReceiveVerifyResponse(peerID, payload)
}
override fun didResolvePrivateMediaPolicy(peerID: String) {
mediaSendingManager.retryPendingPrivateMedia(peerID)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel)
@@ -12,6 +12,9 @@ import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
data class LegacyPrivateMediaConsentRequest(
val requestId: String,
@@ -28,6 +31,7 @@ class MediaSendingManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val channelManager: ChannelManager,
private val scope: CoroutineScope,
private val getMeshService: () -> MeshService
) {
// Helper to get current mesh service (may change after panic clear)
@@ -36,6 +40,7 @@ class MediaSendingManager(
companion object {
private const val TAG = "MediaSendingManager"
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
private const val PENDING_PRIVATE_MEDIA_TIMEOUT_MS = 15_000L
}
// Track in-flight transfer progress: transferId -> messageId and reverse
@@ -57,6 +62,21 @@ class MediaSendingManager(
private var pendingPrivateMedia: PendingPrivateMedia? = null
private data class PendingAutomaticPrivateMedia(
val requestId: String,
val peerID: String,
val filePacket: BitchatFilePacket,
val filePath: String,
val messageType: BitchatMessageType,
val transferId: String,
val allowLegacyFallback: Boolean
)
private var pendingAutomaticPrivateMedia: PendingAutomaticPrivateMedia? = null
private var evaluatingAutomaticRequestId: String? = null
private var automaticRetryRequestedFor: String? = null
private var pendingAutomaticTimeoutRequestId: String? = null
/**
* Send a voice note (audio file)
*/
@@ -213,61 +233,23 @@ class MediaSendingManager(
Log.d(TAG, "📤 FILE_TRANSFER send (private): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, to=${toPeerID.take(8)} transferId=${transferId.take(16)}")
when (val preparation = meshService.prepareFilePrivate(
recipientPeerID = toPeerID,
file = filePacket,
val pending = PendingAutomaticPrivateMedia(
requestId = UUID.randomUUID().toString(),
peerID = toPeerID,
filePacket = filePacket,
filePath = filePath,
messageType = messageType,
transferId = transferId,
allowLegacyFallback = false
)) {
is PrivateMediaPreparation.Ready -> commitPreparedPrivateFile(
preparation = preparation,
toPeerID = toPeerID,
filePath = filePath,
messageType = messageType,
transferId = transferId
)
if (!reserveAutomaticPending(pending)) {
addPrivateMediaSystemMessage(
toPeerID,
"Private media was not sent because another secure media send is still pending."
)
is PrivateMediaPreparation.RequiresLegacyConsent -> {
val nickname = try {
meshService.getPeerNicknames()[toPeerID]
} catch (_: Exception) {
null
} ?: toPeerID.take(8)
val request = LegacyPrivateMediaConsentRequest(
requestId = UUID.randomUUID().toString(),
recipientNickname = nickname,
fileName = filePacket.fileName,
warning = preparation.warning
)
synchronized(pendingConsentLock) {
if (pendingPrivateMedia != null) {
Log.w(TAG, "A legacy private-media consent prompt is already pending")
return
}
pendingPrivateMedia = PendingPrivateMedia(
request,
toPeerID,
filePacket,
filePath,
messageType,
transferId
)
_legacyPrivateMediaConsent.value = request
}
}
PrivateMediaPreparation.NeedsHandshake -> {
Log.i(TAG, "Private media needs a Noise handshake; initiating now, with no local echo")
try {
meshService.initiateNoiseHandshake(toPeerID)
} catch (e: Exception) {
Log.w(TAG, "Could not initiate private-media Noise handshake: ${e.message}")
}
}
is PrivateMediaPreparation.Rejected ->
Log.w(TAG, "Private media not sent: ${preparation.reason}")
return
}
evaluateAutomaticPending(pending)
}
/**
@@ -277,32 +259,23 @@ class MediaSendingManager(
*/
fun approveLegacyPrivateMedia(requestId: String) {
val pending = consumePendingConsent(requestId) ?: return
when (val preparation = meshService.prepareFilePrivate(
recipientPeerID = pending.peerID,
file = pending.filePacket,
val automatic = PendingAutomaticPrivateMedia(
requestId = UUID.randomUUID().toString(),
peerID = pending.peerID,
filePacket = pending.filePacket,
filePath = pending.filePath,
messageType = pending.messageType,
transferId = pending.transferId,
allowLegacyFallback = true
)) {
is PrivateMediaPreparation.Ready -> commitPreparedPrivateFile(
preparation,
)
if (!reserveAutomaticPending(automatic)) {
addPrivateMediaSystemMessage(
pending.peerID,
pending.filePath,
pending.messageType,
pending.transferId
"Private media was not sent because another secure media send is still pending."
)
is PrivateMediaPreparation.RequiresLegacyConsent ->
Log.w(TAG, "Legacy consent was consumed but policy still requested consent; send aborted")
PrivateMediaPreparation.NeedsHandshake -> {
Log.i(TAG, "Noise session disappeared before consent approval; initiating a new handshake")
try {
meshService.initiateNoiseHandshake(pending.peerID)
} catch (e: Exception) {
Log.w(TAG, "Could not re-initiate private-media Noise handshake: ${e.message}")
}
}
is PrivateMediaPreparation.Rejected ->
Log.w(TAG, "Private media policy changed before approval: ${preparation.reason}")
return
}
evaluateAutomaticPending(automatic)
}
fun cancelLegacyPrivateMedia(requestId: String) {
@@ -312,10 +285,221 @@ class MediaSendingManager(
fun clearPendingPrivateMediaConsent() {
synchronized(pendingConsentLock) {
pendingPrivateMedia = null
pendingAutomaticPrivateMedia = null
evaluatingAutomaticRequestId = null
automaticRetryRequestedFor = null
pendingAutomaticTimeoutRequestId = null
_legacyPrivateMediaConsent.value = null
}
}
/** Retry the exact first-send intent after peer-state proof or watchdog resolution. */
fun retryPendingPrivateMedia(peerID: String) {
scope.launch { retryPendingPrivateMediaOnScope(peerID) }
}
private fun retryPendingPrivateMediaOnScope(peerID: String) {
val pending = synchronized(pendingConsentLock) {
pendingAutomaticPrivateMedia
?.takeIf { it.peerID == peerID }
} ?: return
evaluateAutomaticPending(pending)
}
private fun evaluateAutomaticPending(pending: PendingAutomaticPrivateMedia) {
val acquired = synchronized(pendingConsentLock) {
if (pendingAutomaticPrivateMedia?.requestId != pending.requestId) {
return@synchronized false
}
if (evaluatingAutomaticRequestId == pending.requestId) {
automaticRetryRequestedFor = pending.requestId
return@synchronized false
}
evaluatingAutomaticRequestId = pending.requestId
true
}
if (!acquired) return
while (true) {
val preparation = try {
meshService.prepareFilePrivate(
recipientPeerID = pending.peerID,
file = pending.filePacket,
transferId = pending.transferId,
allowLegacyFallback = pending.allowLegacyFallback
)
} catch (error: Exception) {
PrivateMediaPreparation.Rejected(
error.message ?: "Secure private-media preparation failed"
)
}
val stillCurrent = synchronized(pendingConsentLock) {
pendingAutomaticPrivateMedia?.requestId == pending.requestId
}
if (stillCurrent) handlePrivatePreparation(preparation, pending)
val rerun = synchronized(pendingConsentLock) {
if (evaluatingAutomaticRequestId == pending.requestId) {
evaluatingAutomaticRequestId = null
}
val requested = automaticRetryRequestedFor == pending.requestId &&
pendingAutomaticPrivateMedia?.requestId == pending.requestId
if (automaticRetryRequestedFor == pending.requestId) {
automaticRetryRequestedFor = null
}
if (requested) evaluatingAutomaticRequestId = pending.requestId
requested
}
if (!rerun) return
}
}
private fun handlePrivatePreparation(
preparation: PrivateMediaPreparation,
pending: PendingAutomaticPrivateMedia
) {
when (preparation) {
is PrivateMediaPreparation.Ready -> {
clearAutomaticPending(pending.requestId)
commitPreparedPrivateFile(
preparation,
pending.peerID,
pending.filePath,
pending.messageType,
pending.transferId
)
}
is PrivateMediaPreparation.RequiresLegacyConsent -> {
clearAutomaticPending(pending.requestId)
if (pending.allowLegacyFallback) {
Log.w(TAG, "Legacy consent was consumed but policy still requested consent; send aborted")
addPrivateMediaSystemMessage(
pending.peerID,
"Private media was not sent because its security policy changed."
)
return
}
val nickname = try {
meshService.getPeerNicknames()[pending.peerID]
} catch (_: Exception) {
null
} ?: pending.peerID.take(8)
val request = LegacyPrivateMediaConsentRequest(
requestId = UUID.randomUUID().toString(),
recipientNickname = nickname,
fileName = pending.filePacket.fileName,
warning = preparation.warning
)
synchronized(pendingConsentLock) {
if (pendingPrivateMedia != null) {
Log.w(TAG, "A legacy private-media consent prompt is already pending")
return
}
pendingPrivateMedia = PendingPrivateMedia(
request,
pending.peerID,
pending.filePacket,
pending.filePath,
pending.messageType,
pending.transferId
)
_legacyPrivateMediaConsent.value = request
}
}
PrivateMediaPreparation.NeedsHandshake -> {
ensureAutomaticPendingTimeout(pending)
Log.i(TAG, "Private media needs a Noise handshake; retaining first-send intent")
try {
meshService.initiateNoiseHandshake(pending.peerID)
} catch (e: Exception) {
Log.w(TAG, "Could not initiate private-media Noise handshake: ${e.message}")
}
}
PrivateMediaPreparation.AwaitingPeerState -> {
ensureAutomaticPendingTimeout(pending)
Log.i(TAG, "Private media is waiting for authenticated peer state; first-send intent retained")
}
is PrivateMediaPreparation.Rejected -> {
clearAutomaticPending(pending.requestId)
Log.w(TAG, "Private media not sent: ${preparation.reason}")
addPrivateMediaSystemMessage(
pending.peerID,
"Private media was not sent: ${preparation.reason}"
)
}
}
}
private fun reserveAutomaticPending(pending: PendingAutomaticPrivateMedia): Boolean =
synchronized(pendingConsentLock) {
if (pendingAutomaticPrivateMedia != null) return@synchronized false
pendingAutomaticPrivateMedia = pending
true
}
private fun ensureAutomaticPendingTimeout(pending: PendingAutomaticPrivateMedia) {
val shouldStart = synchronized(pendingConsentLock) {
if (pendingAutomaticPrivateMedia?.requestId != pending.requestId ||
pendingAutomaticTimeoutRequestId == pending.requestId
) return@synchronized false
pendingAutomaticTimeoutRequestId = pending.requestId
true
}
if (!shouldStart) return
scope.launch {
delay(PENDING_PRIVATE_MEDIA_TIMEOUT_MS)
val expired = synchronized(pendingConsentLock) {
if (pendingAutomaticPrivateMedia?.requestId != pending.requestId) false
else {
pendingAutomaticPrivateMedia = null
if (automaticRetryRequestedFor == pending.requestId) {
automaticRetryRequestedFor = null
}
if (pendingAutomaticTimeoutRequestId == pending.requestId) {
pendingAutomaticTimeoutRequestId = null
}
true
}
}
if (expired) {
addPrivateMediaSystemMessage(
pending.peerID,
"Private media was not sent because secure session setup timed out."
)
}
}
}
private fun clearAutomaticPending(requestId: String) {
synchronized(pendingConsentLock) {
if (pendingAutomaticPrivateMedia?.requestId == requestId) {
pendingAutomaticPrivateMedia = null
}
if (automaticRetryRequestedFor == requestId) automaticRetryRequestedFor = null
if (pendingAutomaticTimeoutRequestId == requestId) {
pendingAutomaticTimeoutRequestId = null
}
}
}
private fun addPrivateMediaSystemMessage(peerID: String, text: String) {
messageManager.addPrivateMessageNoUnread(
peerID,
BitchatMessage(
sender = "system",
content = text,
timestamp = Date(),
isRelay = false,
isPrivate = true,
senderPeerID = peerID
)
)
}
private fun consumePendingConsent(requestId: String): PendingPrivateMedia? {
return synchronized(pendingConsentLock) {
val pending = pendingPrivateMedia
@@ -369,6 +553,10 @@ class MediaSendingManager(
messageTransferMap.remove(msg.id)
}
Log.w(TAG, "Prepared private-media commit failed; local echo rolled back")
addPrivateMediaSystemMessage(
toPeerID,
"Private media was not sent because the prepared transfer could not be committed."
)
return
}
Log.d(TAG, "✅ Private media committed using ${preparation.transfer.wireMode}")
@@ -1,6 +1,10 @@
package com.bitchat.android.identity
import android.content.Context
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -33,18 +37,37 @@ class PrivateMediaCapabilityPinPersistenceTest {
}
@Test
fun `capability pin persists and panic identity wipe removes it`() {
manager.markPrivateMediaCapable(fingerprint)
fun `authenticated Ed key and capabilities persist rotate and clear atomically`() {
val firstKey = ByteArray(32) { 0x11 }
val rotatedKey = ByteArray(32) { 0x22 }
assertTrue(manager.storeAuthenticatedPeerState(
fingerprint,
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, firstKey)
))
val reloaded = SecureIdentityStateManager(prefs, testOnly = true)
assertArrayEquals(firstKey, reloaded.getAuthenticatedSigningKey(fingerprint))
assertEquals(
PeerCapabilities.PRIVATE_MEDIA,
reloaded.getAuthenticatedPeerState(fingerprint)?.capabilities
)
assertTrue(reloaded.isPrivateMediaCapable(fingerprint))
assertTrue(reloaded.storeAuthenticatedPeerState(
fingerprint,
AuthenticatedPeerState(PeerCapabilities.NONE, rotatedKey)
))
assertArrayEquals(rotatedKey, reloaded.getAuthenticatedSigningKey(fingerprint))
// HSTS-style private-media history is not erased by a no-bit proof.
assertTrue(reloaded.isPrivateMediaCapable(fingerprint))
reloaded.clearIdentityData()
// Simulate an old BLE/Wi-Fi controller finishing a pre-panic callback
// after another manager performed the wipe.
manager.markPrivateMediaCapable(fingerprint)
assertFalse(manager.storeAuthenticatedPeerState(
fingerprint,
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, firstKey)
))
val afterPanic = SecureIdentityStateManager(prefs, testOnly = true)
assertEquals(null, afterPanic.getAuthenticatedPeerState(fingerprint))
assertFalse(afterPanic.isPrivateMediaCapable(fingerprint))
assertTrue(afterPanic.getPrivateMediaCapabilityPinsForTesting().isEmpty())
}
}
@@ -0,0 +1,299 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoisePeerIdentity
import java.security.MessageDigest
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AuthenticatedPeerStateCoordinatorTest {
private class MemoryStore : AuthenticatedPeerStateStore {
val states = mutableMapOf<String, AuthenticatedPeerState>()
val pins = mutableSetOf<String>()
override fun load(fingerprint: String): AuthenticatedPeerState? = states[fingerprint]
override fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean {
states[fingerprint] = state
if (state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) pins += fingerprint
onCommitted()
return true
}
override fun isPrivateMediaPinned(fingerprint: String): Boolean = fingerprint in pins
}
private val remoteStatic = ByteArray(32) { 0x33 }
private val peerID = NoisePeerIdentity.derivePeerID(remoteStatic)!!
private val firstSession = AuthenticatedNoiseSession(
remoteStatic,
ByteArray(32) { 0x71 }
)
private val secondSession = AuthenticatedNoiseSession(
remoteStatic,
ByteArray(32) { 0x72 }
)
private val localState = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 0x44 })
private val remoteState = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 0x55 })
@Test
fun `every generation emits and first valid proof echoes exactly once`() {
val store = MemoryStore()
val sent = CopyOnWriteArrayList<AuthenticatedPeerState>()
val applied = CopyOnWriteArrayList<AuthenticatedPeerState>()
var activeSession = firstSession
val coordinator = AuthenticatedPeerStateCoordinator(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
authenticatedSessionProvider = { activeSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == activeSession) action() else false
},
store = store,
localStateProvider = { localState },
applyAuthenticatedState = { _, key, state ->
assertArrayEquals(remoteStatic, key)
applied += state
},
sendState = { _, state, _ -> sent.add(state) },
onResolution = {},
proofTimeoutMs = 5_000
)
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession))
assertEquals(1, sent.size)
assertTrue(coordinator.receive(peerID, remoteState, firstSession))
assertEquals(2, sent.size)
assertTrue(coordinator.receive(peerID, remoteState, firstSession))
assertEquals("Repeated proof must not echo again", 2, sent.size)
assertEquals(1, applied.size)
val different = AuthenticatedPeerState(PeerCapabilities.NONE, ByteArray(32) { 0x66 })
assertFalse(
"A generation cannot replace its first proof",
coordinator.receive(peerID, different, firstSession)
)
activeSession = secondSession
// Policy can observe the crypto swap before its completion callback. It must adopt the
// new token as Awaiting instead of reusing gen-N Proven state.
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession))
assertEquals(3, sent.size)
coordinator.onSessionAuthenticated(peerID, remoteStatic, secondSession.sessionToken)
assertEquals(3, sent.size)
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession))
assertFalse(coordinator.receive(peerID, remoteState, firstSession))
assertTrue(coordinator.receive(peerID, different, secondSession))
assertEquals(4, sent.size)
assertEquals(2, applied.size)
}
@Test
fun `live generation is adopted once and watchdog is never reset by policy reads`() = runBlocking {
val sent = CopyOnWriteArrayList<AuthenticatedPeerState>()
val resolutions = CopyOnWriteArrayList<String>()
val coordinator = AuthenticatedPeerStateCoordinator(
scope = this,
authenticatedSessionProvider = { firstSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == firstSession) action() else false
},
store = MemoryStore(),
localStateProvider = { localState },
applyAuthenticatedState = { _, _, _ -> },
sendState = { _, state, _ -> sent += state; true },
onResolution = resolutions::add,
proofTimeoutMs = 20
)
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession))
delay(10)
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession))
delay(25)
assertEquals(AuthenticatedPeerStateStatus.TimedOut, coordinator.status(peerID, firstSession))
assertEquals(1, sent.size)
assertEquals(listOf(peerID), resolutions)
}
@Test
fun `delayed old completion cannot replace a newer tracked generation`() {
val sentTokens = CopyOnWriteArrayList<ByteArray>()
var activeSession = secondSession
val coordinator = AuthenticatedPeerStateCoordinator(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
authenticatedSessionProvider = { activeSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == activeSession) action() else false
},
store = MemoryStore(),
localStateProvider = { localState },
applyAuthenticatedState = { _, _, _ -> },
sendState = { _, _, session -> sentTokens += session.sessionToken; true },
onResolution = {},
proofTimeoutMs = 5_000
)
coordinator.onSessionAuthenticated(peerID, remoteStatic, secondSession.sessionToken)
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession))
assertEquals(1, sentTokens.size)
assertArrayEquals(secondSession.sessionToken, sentTokens.single())
}
@Test
fun `watchdog resolves no-proof generation without trusting persisted prior state`() = runBlocking {
val store = MemoryStore()
store.states[fingerprint(remoteStatic)] = remoteState
val resolutions = CopyOnWriteArrayList<String>()
val coordinator = AuthenticatedPeerStateCoordinator(
scope = this,
authenticatedSessionProvider = { firstSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == firstSession) action() else false
},
store = store,
localStateProvider = { localState },
applyAuthenticatedState = { _, _, _ -> },
sendState = { _, _, _ -> true },
onResolution = resolutions::add,
proofTimeoutMs = 20
)
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
delay(50)
assertEquals(AuthenticatedPeerStateStatus.TimedOut, coordinator.status(peerID, firstSession))
assertEquals(listOf(peerID), resolutions)
}
@Test
fun `copied-static preannounce Ed key is replaced by authenticated proof`() {
val peerManager = PeerManager()
val attackerEd = ByteArray(32) { 0x11 }
val victimEd = ByteArray(32) { 0x22 }
peerManager.updatePeerInfoFromVerifiedAnnouncement(
peerID,
"attacker-name",
remoteStatic,
attackerEd,
true,
PeerCapabilities.PRIVATE_MEDIA
)
val coordinator = AuthenticatedPeerStateCoordinator(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
authenticatedSessionProvider = { firstSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == firstSession) action() else false
},
store = MemoryStore(),
localStateProvider = { localState },
applyAuthenticatedState = peerManager::applyAuthenticatedPeerState,
sendState = { _, _, _ -> true },
onResolution = {},
proofTimeoutMs = 5_000
)
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
assertTrue(
coordinator.receive(
peerID,
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, victimEd),
firstSession
)
)
val peer = peerManager.getPeerInfo(peerID)!!
assertArrayEquals(victimEd, peer.signingPublicKey)
assertEquals(peerID, peer.nickname)
assertFalse("Copied self-signed nickname must lose verified status", peer.isVerifiedNickname)
assertFalse(peer.hasVerifiedAnnouncement)
}
@Test
fun `failed durable persistence cannot publish peer state in memory`() {
val applied = CopyOnWriteArrayList<AuthenticatedPeerState>()
val store = object : AuthenticatedPeerStateStore {
override fun load(fingerprint: String): AuthenticatedPeerState? = null
override fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean = false
override fun isPrivateMediaPinned(fingerprint: String): Boolean = false
}
val coordinator = AuthenticatedPeerStateCoordinator(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
authenticatedSessionProvider = { firstSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == firstSession) action() else false
},
store = store,
localStateProvider = { localState },
applyAuthenticatedState = { _, _, state -> applied += state },
sendState = { _, _, _ -> true },
onResolution = {},
proofTimeoutMs = 5_000
)
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
assertFalse(coordinator.receive(peerID, remoteState, firstSession))
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession))
assertTrue(applied.isEmpty())
}
@Test
fun `stale decryption lease cannot persist or apply after generation replacement`() {
var activeSession = firstSession
var persistCalls = 0
var applyCalls = 0
val store = object : AuthenticatedPeerStateStore {
override fun load(fingerprint: String): AuthenticatedPeerState? = null
override fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean {
persistCalls += 1
onCommitted()
return true
}
override fun isPrivateMediaPinned(fingerprint: String): Boolean = false
}
val coordinator = AuthenticatedPeerStateCoordinator(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
authenticatedSessionProvider = { activeSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == activeSession) action() else false
},
store = store,
localStateProvider = { localState },
applyAuthenticatedState = { _, _, _ -> applyCalls += 1 },
sendState = { _, _, _ -> true },
onResolution = {},
proofTimeoutMs = 5_000
)
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
activeSession = secondSession
assertFalse(coordinator.receive(peerID, remoteState, firstSession))
assertEquals(0, persistCalls)
assertEquals(0, applyCalls)
}
private fun fingerprint(key: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(key).joinToString("") { "%02x".format(it) }
}
@@ -2,10 +2,15 @@ package com.bitchat.android.mesh
import android.os.Build
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoisePeerIdentity
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoiseDecryptionResult
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
@@ -38,6 +43,10 @@ class MessageHandlerTest {
private val myPeerID = "1111222233334444"
private val noiseKey = ByteArray(32) { 0x0B }
private val peerID = NoisePeerIdentity.derivePeerID(noiseKey)!!
private val authenticatedSession = AuthenticatedNoiseSession(
noiseKey,
ByteArray(32) { 0x5D }
)
private val nickname = "peer"
private val signingKey = ByteArray(32) { 0x0A }
private val signature = ByteArray(64) { 1 }
@@ -216,7 +225,9 @@ class MessageHandlerTest {
)
val prereleasePlaintext = byteArrayOf(0x09) + file.encode()!!
val ciphertext = byteArrayOf(0x41, 0x42, 0x43)
whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(prereleasePlaintext)
whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(
NoiseDecryptionResult(prereleasePlaintext, authenticatedSession)
)
whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname)
whenever(delegate.getMyNickname()).thenReturn("me")
whenever(delegate.encryptForPeer(any(), eq(peerID))).thenReturn(byteArrayOf(0x55))
@@ -239,6 +250,41 @@ class MessageHandlerTest {
}
}
@Test
fun `valid encrypted peer state is delivered and malformed state is ignored`() = runBlocking {
val state = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey)
val validCiphertext = byteArrayOf(0x31)
val malformedCiphertext = byteArrayOf(0x32)
whenever(delegate.decryptFromPeer(validCiphertext, peerID)).thenReturn(
NoiseDecryptionResult(
NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode(),
authenticatedSession
)
)
whenever(delegate.decryptFromPeer(malformedCiphertext, peerID)).thenReturn(
NoiseDecryptionResult(
NoisePayload(NoisePayloadType.PEER_STATE, byteArrayOf(0x01, 0x01)).encode(),
authenticatedSession
)
)
val base = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = peerID.hexToBytes(),
recipientID = myPeerID.hexToBytes(),
timestamp = System.currentTimeMillis().toULong(),
payload = validCiphertext,
ttl = 7u
)
handler.handleNoiseEncrypted(RoutedPacket(base, peerID, "direct-link"))
handler.handleNoiseEncrypted(
RoutedPacket(base.copy(payload = malformedCiphertext), peerID, "direct-link")
)
verify(delegate).onAuthenticatedPeerStateReceived(peerID, state, authenticatedSession)
}
@Test
fun `first self-signed announce cannot claim an ID derived from another Noise key`() = runBlocking {
val attackerNoiseKey = ByteArray(32) { 0x6B }
@@ -314,6 +360,20 @@ class MessageHandlerTest {
Unit
}
@Test
fun `persisted authenticated Ed key rejects copied-static preannounce after restart`() = runBlocking {
whenever(delegate.getAuthenticatedSigningKey(any())).thenReturn(ByteArray(32) { 0x44 })
val packet = announcePacket(ageMs = 0)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertFalse(result)
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull()
)
Unit
}
private fun announcePacket(
ageMs: Long,
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(),
@@ -1,144 +1,69 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.noise.AuthenticatedNoiseSession
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PrivateMediaSecurityTest {
private class MemoryPins : PrivateMediaCapabilityPinStore {
val pins = mutableSetOf<String>()
override fun contains(fingerprint: String): Boolean = fingerprint in pins
override fun insert(fingerprint: String) {
pins += fingerprint
}
}
private val peerID = "0011223344556677"
private val remoteStatic = ByteArray(32) { (it + 1).toByte() }
private var peerInfo: PeerInfo? = null
private var authenticatedRemoteStatic: ByteArray? = null
private val pins = MemoryPins()
private var authenticatedSession: AuthenticatedNoiseSession? = AuthenticatedNoiseSession(
ByteArray(32) { (it + 1).toByte() },
ByteArray(32) { 0x51 }
)
private var status: AuthenticatedPeerStateStatus = AuthenticatedPeerStateStatus.Awaiting
private var pinned = false
private val controller = PrivateMediaSecurityController(
peerInfoProvider = { peerInfo },
authenticatedRemoteStaticProvider = { authenticatedRemoteStatic?.copyOf() },
pinStore = pins
authenticatedSessionProvider = { authenticatedSession },
peerStateStatusProvider = { _, _ -> status },
isPrivateMediaPinned = { pinned }
)
@Test
fun `announce before handshake promotes only after authenticated key arrives`() {
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA)
assertFalse(controller.refreshAuthenticatedCapability(peerID))
assertTrue(pins.pins.isEmpty())
authenticatedRemoteStatic = remoteStatic
assertTrue(controller.refreshAuthenticatedCapability(peerID))
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
assertEquals(1, pins.pins.size)
fun `live session waits for fresh generation proof`() {
assertEquals(PrivateMediaPolicyDecision.AwaitingPeerState, controller.sendPolicy(peerID))
}
@Test
fun `handshake before announce promotes only after verified announce arrives`() {
authenticatedRemoteStatic = remoteStatic
assertFalse(controller.refreshAuthenticatedCapability(peerID))
assertTrue(pins.pins.isEmpty())
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA)
assertTrue(controller.refreshAuthenticatedCapability(peerID))
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
}
@Test
fun `self certified or mismatched announcement never creates pin`() {
authenticatedRemoteStatic = remoteStatic
peerInfo = peer(
capabilities = PeerCapabilities.PRIVATE_MEDIA,
noiseKey = ByteArray(32) { 0x55 }
fun `valid private-media proof enables encrypted wire`() {
status = AuthenticatedPeerStateStatus.Proven(
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 1 })
)
assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Encrypted)
}
assertFalse(controller.refreshAuthenticatedCapability(peerID))
assertTrue(pins.pins.isEmpty())
@Test
fun `no-bit proof permits consent only when capability was never pinned`() {
status = AuthenticatedPeerStateStatus.Proven(
AuthenticatedPeerState(PeerCapabilities.NONE, ByteArray(32) { 1 })
)
assertEquals(PrivateMediaPolicyDecision.RequiresLegacyConsent, controller.sendPolicy(peerID))
pinned = true
assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked)
}
// A normal peer refresh may carry a new current key, but it cannot
// transfer capability trust away from the key in the signed announce.
peerInfo = peer(
capabilities = PeerCapabilities.PRIVATE_MEDIA,
noiseKey = remoteStatic,
verifiedAnnouncementNoiseKey = ByteArray(32) { 0x55 }
)
assertFalse(controller.refreshAuthenticatedCapability(peerID))
@Test
fun `no-proof timeout permits old-client consent but blocks pinned downgrade`() {
status = AuthenticatedPeerStateStatus.TimedOut
assertEquals(PrivateMediaPolicyDecision.RequiresLegacyConsent, controller.sendPolicy(peerID))
pinned = true
assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked)
peerInfo = peer(
capabilities = PeerCapabilities.PRIVATE_MEDIA,
hasVerifiedAnnouncement = false
)
assertFalse(controller.refreshAuthenticatedCapability(peerID))
assertTrue(pins.pins.isEmpty())
}
@Test
fun `signed absent and explicit empty capabilities both require one shot consent`() {
authenticatedRemoteStatic = remoteStatic
peerInfo = peer(capabilities = null)
assertEquals(
PrivateMediaPolicyDecision.RequiresLegacyConsent,
controller.sendPolicy(peerID)
)
peerInfo = peer(capabilities = PeerCapabilities.NONE)
assertEquals(
PrivateMediaPolicyDecision.RequiresLegacyConsent,
controller.sendPolicy(peerID)
)
assertTrue(pins.pins.isEmpty())
fun `live session missing coordinator generation waits and never unlocks legacy`() {
status = AuthenticatedPeerStateStatus.Missing
assertEquals(PrivateMediaPolicyDecision.AwaitingPeerState, controller.sendPolicy(peerID))
}
@Test
fun `pin is HSTS and prevents later capability downgrade`() {
authenticatedRemoteStatic = remoteStatic
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA)
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
peerInfo = peer(capabilities = null)
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
}
@Test
fun `pin never bypasses requirement for a live authenticated static key`() {
authenticatedRemoteStatic = remoteStatic
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA)
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
authenticatedRemoteStatic = null
fun `pin never bypasses requirement for live authenticated session`() {
pinned = true
authenticatedSession = null
assertEquals(PrivateMediaPolicyDecision.NeedsHandshake, controller.sendPolicy(peerID))
}
private fun peer(
capabilities: PeerCapabilities?,
noiseKey: ByteArray = remoteStatic,
hasVerifiedAnnouncement: Boolean = true,
verifiedAnnouncementNoiseKey: ByteArray = noiseKey
) = PeerInfo(
id = peerID,
nickname = "peer",
isConnected = true,
isDirectConnection = true,
noisePublicKey = noiseKey,
signingPublicKey = ByteArray(32) { 9 },
isVerifiedNickname = true,
lastSeen = 1,
capabilities = capabilities,
hasVerifiedAnnouncement = hasVerifiedAnnouncement,
verifiedAnnouncementNoisePublicKey = if (hasVerifiedAnnouncement) {
verifiedAnnouncementNoiseKey
} else {
null
}
)
}
@@ -5,6 +5,7 @@ import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.noise.AuthenticatedNoiseSession
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -18,6 +19,10 @@ import java.util.Random
class PrivateMediaTransferPreparerTest {
private val senderID = hex("0011223344556677")
private val recipientID = hex("8877665544332211")
private val authenticatedSession = AuthenticatedNoiseSession(
ByteArray(32) { 0x21 },
ByteArray(32) { 0x22 }
)
private val fragmentManagers = mutableListOf<FragmentManager>()
@After
@@ -29,10 +34,10 @@ class PrivateMediaTransferPreparerTest {
fun `encrypted mode emits deployed Noise 0x20 and signs before fragmentation`() {
var encryptedPlaintext: ByteArray? = null
val preparer = preparer(
policy = PrivateMediaPolicyDecision.Encrypted,
encrypt = { bytes, _ ->
policy = PrivateMediaPolicyDecision.Encrypted(authenticatedSession),
encrypt = { bytes, _, _ ->
encryptedPlaintext = bytes
byteArrayOf(0x41) + bytes
PrivateMediaEncryptionResult.Success(byteArrayOf(0x41) + bytes)
}
)
@@ -101,9 +106,9 @@ class PrivateMediaTransferPreparerTest {
senderID = senderID,
ttl = 7u,
policyProvider = { PrivateMediaPolicyDecision.NeedsHandshake },
encrypt = { _, _ ->
encrypt = { _, _, _ ->
encrypted = true
byteArrayOf(1)
PrivateMediaEncryptionResult.Success(byteArrayOf(1))
},
finalizeRoutedAndSigned = {
finalized = true
@@ -123,6 +128,38 @@ class PrivateMediaTransferPreparerTest {
assertTrue(!fragmented)
}
@Test
fun `peer-state wait returns before encoding signing encryption or fragmentation`() {
var encrypted = false
var finalized = false
var fragmented = false
val fragmentManager = FragmentManager().also { fragmentManagers += it }
val preparer = PrivateMediaTransferPreparer(
senderID = senderID,
ttl = 7u,
policyProvider = { PrivateMediaPolicyDecision.AwaitingPeerState },
encrypt = { _, _, _ ->
encrypted = true
PrivateMediaEncryptionResult.Success(byteArrayOf(1))
},
finalizeRoutedAndSigned = {
finalized = true
it
},
fragment = { packet, maxFragments ->
fragmented = true
fragmentManager.createFragments(packet, maxFragments)
}
)
val outcome = preparer.prepare("peer", recipientID, file(64), false)
assertEquals(PrivateMediaBuildOutcome.AwaitingPeerState, outcome)
assertTrue(!encrypted)
assertTrue(!finalized)
assertTrue(!fragmented)
}
@Test
fun `no route accepts 256 final fragments and rejects 257`() {
assertExactBoundary(route = null)
@@ -139,10 +176,62 @@ class PrivateMediaTransferPreparerTest {
)
}
@Test
fun `generation churn re-runs policy once instead of losing the send intent`() {
val replacementSession = AuthenticatedNoiseSession(
authenticatedSession.remoteStaticKey,
ByteArray(32) { 0x23 }
)
var policyCalls = 0
val fragmentManager = FragmentManager().also { fragmentManagers += it }
val preparer = PrivateMediaTransferPreparer(
senderID = senderID,
ttl = 7u,
policyProvider = {
policyCalls += 1
PrivateMediaPolicyDecision.Encrypted(
if (policyCalls == 1) authenticatedSession else replacementSession
)
},
encrypt = { bytes, _, session ->
if (session == authenticatedSession) {
PrivateMediaEncryptionResult.GenerationChanged
} else {
PrivateMediaEncryptionResult.Success(byteArrayOf(0x41) + bytes)
}
},
finalizeRoutedAndSigned = { it.copy(signature = ByteArray(64) { 0x33 }) },
fragment = fragmentManager::createFragments
)
val outcome = preparer.prepare("peer", recipientID, file(64), false)
assertTrue(outcome is PrivateMediaBuildOutcome.Ready)
assertEquals(2, policyCalls)
}
@Test
fun `repeated generation churn remains retryable`() {
val fragmentManager = FragmentManager().also { fragmentManagers += it }
val preparer = PrivateMediaTransferPreparer(
senderID = senderID,
ttl = 7u,
policyProvider = { PrivateMediaPolicyDecision.Encrypted(authenticatedSession) },
encrypt = { _, _, _ -> PrivateMediaEncryptionResult.GenerationChanged },
finalizeRoutedAndSigned = { it.copy(signature = ByteArray(64) { 0x33 }) },
fragment = fragmentManager::createFragments
)
assertEquals(
PrivateMediaBuildOutcome.AwaitingPeerState,
preparer.prepare("peer", recipientID, file(64), false)
)
}
private fun assertExactBoundary(route: List<ByteArray>?) {
val randomContent = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) }
val preparer = preparer(
policy = PrivateMediaPolicyDecision.Encrypted,
policy = PrivateMediaPolicyDecision.Encrypted(authenticatedSession),
finalizer = { packet ->
packet.copy(
version = if (route == null) packet.version else 2u,
@@ -180,7 +269,13 @@ class PrivateMediaTransferPreparerTest {
private fun preparer(
policy: PrivateMediaPolicyDecision,
encrypt: (ByteArray, String) -> ByteArray? = { bytes, _ -> byteArrayOf(0x01) + bytes },
encrypt: (
ByteArray,
String,
AuthenticatedNoiseSession
) -> PrivateMediaEncryptionResult = { bytes, _, _ ->
PrivateMediaEncryptionResult.Success(byteArrayOf(0x01) + bytes)
},
finalizer: (BitchatPacket) -> BitchatPacket? = { packet ->
packet.copy(signature = ByteArray(64) { 0x33 })
}
@@ -34,6 +34,7 @@ class SecurityManagerTest {
// Key pairs (using dummy bytes for mock verification)
private val otherSigningKey = ByteArray(32) { 0xA }
private val otherNoiseKey = ByteArray(32) { 0xB }
private val sessionToken = ByteArray(32) { 0x5C }
private val unknownPeerID = NoisePeerIdentity.derivePeerID(otherNoiseKey)!!
private val dummyPayload = "Hello World".toByteArray()
@@ -344,6 +345,21 @@ class SecurityManagerTest {
assertFalse(securityManager.validatePacket(packet, otherPeerID))
}
@Test
fun `validatePacket rejects announce conflicting with persisted authenticated Ed key`() {
whenever(mockDelegate.getAuthenticatedSigningKey(otherNoiseKey))
.thenReturn(ByteArray(32) { 0x44 })
val announcement = IdentityAnnouncement("Copied", otherNoiseKey, otherSigningKey)
val packet = BitchatPacket(
type = MessageType.ANNOUNCE.value,
ttl = 7u,
senderID = unknownPeerID,
payload = announcement.encode()!!
).also { it.signature = validSignature }
assertFalse(securityManager.validatePacket(packet, unknownPeerID))
}
@Test
fun `validatePacket - ignores own packets`() {
val packet = BitchatPacket(
@@ -420,7 +436,9 @@ class SecurityManagerTest {
assertTrue(accepted)
assertTrue(fakeEncryptionService.removePeerCalls == 0)
verify(mockDelegate).sendHandshakeResponse(otherPeerID, response)
verify(mockDelegate, never()).onKeyExchangeCompleted(any(), any(), anyOrNull(), anyOrNull())
verify(mockDelegate, never()).onKeyExchangeCompleted(
any(), any(), any(), anyOrNull(), anyOrNull()
)
}
@Test
@@ -434,19 +452,23 @@ class SecurityManagerTest {
assertFalse(securityManager.handleNoiseHandshake(routed))
assertTrue(fakeEncryptionService.removePeerCalls == 0)
verify(mockDelegate, never()).sendHandshakeResponse(any(), any())
verify(mockDelegate, never()).onKeyExchangeCompleted(any(), any(), anyOrNull(), anyOrNull())
verify(mockDelegate, never()).onKeyExchangeCompleted(
any(), any(), any(), anyOrNull(), anyOrNull()
)
fakeEncryptionService.handshakeError = null
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
response = null,
establishedNow = true,
authenticatedRemoteStaticKey = otherNoiseKey
authenticatedRemoteStaticKey = otherNoiseKey,
authenticatedSessionToken = sessionToken
)
assertTrue("Failed frames must not poison the processed-exchange cache", securityManager.handleNoiseHandshake(routed))
assertTrue(fakeEncryptionService.handshakeCalls == 2)
verify(mockDelegate).onKeyExchangeCompleted(
otherPeerID,
otherNoiseKey,
sessionToken,
"direct-link",
"direct-link-token"
)
@@ -457,7 +479,8 @@ class SecurityManagerTest {
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
response = null,
establishedNow = true,
authenticatedRemoteStaticKey = otherNoiseKey
authenticatedRemoteStaticKey = otherNoiseKey,
authenticatedSessionToken = sessionToken
)
val routed = handshakePacket(byteArrayOf(0x51, 0x52, 0x53))
@@ -466,6 +489,7 @@ class SecurityManagerTest {
verify(mockDelegate, times(1)).onKeyExchangeCompleted(
otherPeerID,
otherNoiseKey,
sessionToken,
"direct-link",
"direct-link-token"
)
@@ -478,7 +502,8 @@ class SecurityManagerTest {
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
response = null,
establishedNow = true,
authenticatedRemoteStaticKey = otherNoiseKey
authenticatedRemoteStaticKey = otherNoiseKey,
authenticatedSessionToken = sessionToken
)
val routed = handshakePacket(
payload = byteArrayOf(0x61, 0x62, 0x63),
@@ -486,7 +511,13 @@ class SecurityManagerTest {
)
assertTrue(securityManager.handleNoiseHandshake(routed))
verify(mockDelegate).onKeyExchangeCompleted(otherPeerID, otherNoiseKey, null, null)
verify(mockDelegate).onKeyExchangeCompleted(
otherPeerID,
otherNoiseKey,
sessionToken,
null,
null
)
}
private fun setupKnownPeer(peerID: String, signingKey: ByteArray) {
@@ -0,0 +1,68 @@
package com.bitchat.android.model
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AuthenticatedPeerStateTest {
private val signingKey = ByteArray(32) { it.toByte() }
@Test
fun `encoder matches canonical iOS bytes`() {
val encoded = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey).encode()
assertArrayEquals(
byteArrayOf(0x01, 0x01, 0x02, 0x00, 0x01, 0x02, 0x20) + signingKey,
encoded
)
assertEquals(
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey),
AuthenticatedPeerState.decode(encoded)
)
}
@Test
fun `decoder skips unknown TLVs and accepts either known-field order`() {
val payload = byteArrayOf(
0x01,
0x7F, 0x02, 0x55, 0x66,
0x02, 0x20
) + signingKey + byteArrayOf(0x01, 0x01, 0x00)
assertEquals(
AuthenticatedPeerState(PeerCapabilities.NONE, signingKey),
AuthenticatedPeerState.decode(payload)
)
}
@Test
fun `decoder rejects malformed duplicate missing and noncanonical fields`() {
val validCapabilities = byteArrayOf(0x01, 0x01, 0x00)
val validSigning = byteArrayOf(0x02, 0x20) + signingKey
val invalid = listOf(
byteArrayOf(),
byteArrayOf(0x02) + validCapabilities + validSigning,
byteArrayOf(0x01) + validCapabilities,
byteArrayOf(0x01) + validSigning,
byteArrayOf(0x01) + validCapabilities + validCapabilities + validSigning,
byteArrayOf(0x01, 0x01, 0x02, 0x00, 0x00) + validSigning,
byteArrayOf(0x01, 0x01, 0x00) + validSigning,
byteArrayOf(0x01, 0x01, 0x09) + ByteArray(9) + validSigning,
byteArrayOf(0x01, 0x02, 0x1F) + ByteArray(31) + validCapabilities,
byteArrayOf(0x01, 0x7F),
byteArrayOf(0x01, 0x7F, 0x02, 0x01)
)
invalid.forEach { assertNull("Expected rejection for ${it.joinToString()}", AuthenticatedPeerState.decode(it)) }
}
@Test
fun `Noise wrapper emits and decodes canonical 0x21`() {
val state = AuthenticatedPeerState(PeerCapabilities.NONE, signingKey)
val encoded = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode()
assertEquals(0x21, encoded[0].toInt() and 0xFF)
assertEquals(NoisePayloadType.PEER_STATE, NoisePayload.decode(encoded)?.type)
}
}
@@ -10,6 +10,10 @@ import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
class NoiseSessionManagerIdentityBindingTest {
private data class TestIdentity(
@@ -40,8 +44,12 @@ class NoiseSessionManagerIdentityBindingTest {
assertArrayEquals(alice.publicKey, bobManager.getRemoteStaticKey(alice.peerID))
val plaintext = "bound transport".toByteArray()
val ciphertext = aliceManager.encrypt(plaintext, bob.peerID)
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
val aliceSession = aliceManager.getAuthenticatedSession(bob.peerID)!!
val bobSession = bobManager.getAuthenticatedSession(alice.peerID)!!
val ciphertext = aliceManager.encryptForSession(plaintext, bob.peerID, aliceSession)
val decrypted = bobManager.decryptWithSession(ciphertext, alice.peerID)
assertArrayEquals(plaintext, decrypted.plaintext)
assertArrayEquals(bobSession.sessionToken, decrypted.authenticatedSession.sessionToken)
}
@Test
@@ -145,6 +153,7 @@ class NoiseSessionManagerIdentityBindingTest {
completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID)
val originalSession = aliceManager.getSession(bob.peerID)
val originalBinding = aliceManager.getAuthenticatedSession(bob.peerID)!!
// Simulate Bob restarting with the same persistent static identity and no session state.
val restartedBobManager = manager(bob)
@@ -157,12 +166,98 @@ class NoiseSessionManagerIdentityBindingTest {
assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID))
assertTrue(aliceAuthenticatedCallbacks == 2)
val replacementBinding = aliceManager.getAuthenticatedSession(bob.peerID)!!
assertFalse(originalBinding.sessionToken.contentEquals(replacementBinding.sessionToken))
try {
aliceManager.encryptForSession(
"stale generation".toByteArray(),
bob.peerID,
originalBinding
)
fail("Expected stale generation-bound encryption to be rejected")
} catch (_: NoiseSessionError.SessionGenerationChanged) {
// Expected.
}
val plaintext = "replacement transport".toByteArray()
val ciphertext = restartedBobManager.encrypt(plaintext, alice.peerID)
val ciphertext = restartedBobManager.encryptForSession(
plaintext,
alice.peerID,
restartedBobManager.getAuthenticatedSession(alice.peerID)!!
)
assertArrayEquals(plaintext, aliceManager.decrypt(ciphertext, bob.peerID))
}
@Test
fun `generation lease blocks replacement and rejects stale token afterward`() {
val alice = identity()
val bob = identity()
val aliceManager = manager(alice)
val originalBobManager = manager(bob)
completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID)
val originalBinding = aliceManager.getAuthenticatedSession(bob.peerID)!!
val restartedBobManager = manager(bob)
val message1 = restartedBobManager.initiateHandshake(alice.peerID)!!
val message2 = aliceManager.processHandshakeMessage(bob.peerID, message1)!!
val message3 = restartedBobManager.processHandshakeMessage(alice.peerID, message2)!!
val leaseEntered = CountDownLatch(1)
val releaseLease = CountDownLatch(1)
val replacementStarted = CountDownLatch(1)
val replacementCompleted = CountDownLatch(1)
val replacementFinished = AtomicBoolean(false)
val threadFailure = AtomicReference<Throwable?>(null)
val leaseThread = Thread {
try {
assertTrue(
aliceManager.withAuthenticatedSession(bob.peerID, originalBinding) {
leaseEntered.countDown()
releaseLease.await(2, TimeUnit.SECONDS)
}
)
} catch (error: Throwable) {
threadFailure.set(error)
}
}
val replacementThread = Thread {
try {
replacementStarted.countDown()
aliceManager.processHandshakeMessage(bob.peerID, message3)
replacementFinished.set(true)
} catch (error: Throwable) {
threadFailure.set(error)
} finally {
replacementCompleted.countDown()
}
}
leaseThread.start()
assertTrue(leaseEntered.await(1, TimeUnit.SECONDS))
replacementThread.start()
assertTrue(replacementStarted.await(1, TimeUnit.SECONDS))
try {
assertFalse(
"Replacement must wait while the old generation lease is active",
replacementCompleted.await(100, TimeUnit.MILLISECONDS)
)
} finally {
releaseLease.countDown()
}
leaseThread.join(2_000)
replacementThread.join(2_000)
assertTrue(replacementCompleted.await(1, TimeUnit.SECONDS))
threadFailure.get()?.let { throw it }
assertTrue(replacementFinished.get())
try {
aliceManager.encryptForSession(byteArrayOf(1), bob.peerID, originalBinding)
fail("Expected old token to be rejected after replacement")
} catch (_: NoiseSessionError.SessionGenerationChanged) {
// Expected.
}
}
@Test
fun `peer ID derivation rejects malformed keys and non-wire claims`() {
val peer = identity()
@@ -44,6 +44,7 @@ class MediaSendingManagerMigrationTest {
state,
MessageManager(state),
mock(),
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
getMeshService = { mesh }
)
file = kotlin.io.path.createTempFile("private-media", ".jpg").toFile().apply {
@@ -57,28 +58,112 @@ class MediaSendingManagerMigrationTest {
}
@Test
fun `preflight rejection creates no local echo mapping or send`() {
fun `preflight rejection creates only a visible failure and no file echo or send`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.Rejected("too many fragments"))
manager.sendImageNote(peerID, null, file.absolutePath)
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
val messages = state.privateChats.value[peerID].orEmpty()
assertEquals(1, messages.size)
assertTrue(messages.single().content.contains("too many fragments"))
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
assertEquals(null, manager.legacyPrivateMediaConsent.value)
verify(mesh, never()).sendFilePrivate(any(), any())
}
@Test
fun `missing Noise session initiates one handshake without echo prompt or media send`() {
fun `pinned downgrade rejection is visible without a file echo or send`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(
PrivateMediaPreparation.Rejected(
"Encrypted private media was previously pinned, but this session proved no support; send blocked"
)
)
manager.sendImageNote(peerID, null, file.absolutePath)
val messages = state.privateChats.value[peerID].orEmpty()
assertEquals(1, messages.size)
assertTrue(messages.single().content.contains("previously pinned"))
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
verify(mesh, never()).sendFilePrivate(any(), any())
}
@Test
fun `missing Noise session retains first send and commits after proof resolution`() {
val commits = AtomicInteger(0)
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.NeedsHandshake)
.thenAnswer { invocation ->
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(
transferId = invocation.getArgument<String>(2),
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
) {
commits.incrementAndGet()
true
}
)
}
manager.sendImageNote(peerID, null, file.absolutePath)
verify(mesh, times(1)).initiateNoiseHandshake(peerID)
verify(mesh, never()).sendFilePrivate(any(), any())
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
assertEquals(null, manager.legacyPrivateMediaConsent.value)
manager.retryPendingPrivateMedia(peerID)
assertEquals(1, commits.get())
assertEquals(1, state.privateChats.value[peerID]?.size)
verify(mesh, times(2)).prepareFilePrivate(eq(peerID), any(), any(), eq(false))
verify(mesh, never()).sendFilePrivate(any(), any())
}
@Test
fun `awaiting peer state retains send and watchdog resolution offers legacy consent`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.AwaitingPeerState)
.thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning"))
manager.sendImageNote(peerID, null, file.absolutePath)
verify(mesh, never()).initiateNoiseHandshake(peerID)
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
assertEquals(null, manager.legacyPrivateMediaConsent.value)
manager.retryPendingPrivateMedia(peerID)
assertNotNull(manager.legacyPrivateMediaConsent.value)
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
}
@Test
fun `reentrant proof resolution during preparation cannot lose first send intent`() {
val commits = AtomicInteger(0)
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenAnswer {
manager.retryPendingPrivateMedia(peerID)
PrivateMediaPreparation.AwaitingPeerState
}
.thenAnswer { invocation ->
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(
transferId = invocation.getArgument<String>(2),
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
) {
commits.incrementAndGet()
true
}
)
}
manager.sendImageNote(peerID, null, file.absolutePath)
assertEquals(1, commits.get())
assertEquals(1, state.privateChats.value[peerID]?.size)
verify(mesh, times(2)).prepareFilePrivate(eq(peerID), any(), any(), eq(false))
}
@Test