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 androidx.security.crypto.MasterKey
import com.bitchat.android.noise.NoiseEncryptionService import com.bitchat.android.noise.NoiseEncryptionService
import com.bitchat.android.noise.NoiseHandshakeProcessingResult 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.AsymmetricCipherKeyPair
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
@@ -191,6 +193,13 @@ open class EncryptionService(private val context: Context) {
} }
return encrypted 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 * Decrypt data from a specific peer using Noise transport encryption
@@ -203,6 +212,12 @@ open class EncryptionService(private val context: Context) {
} }
return decrypted 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 * 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. * authentication, not a self-certified identity payload.
*/ */
fun getAuthenticatedRemoteStaticKey(peerID: String): ByteArray? { fun getAuthenticatedRemoteStaticKey(peerID: String): ByteArray? {
if (!noiseService.hasEstablishedSession(peerID)) return null return getAuthenticatedSession(peerID)?.remoteStaticKey?.copyOf()
return noiseService.getPeerPublicKeyData(peerID)
?.takeIf { it.size == 32 }
?.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) * Get current peer ID for a fingerprint (for peer ID rotation)
@@ -1,5 +1,6 @@
package com.bitchat.android.identity package com.bitchat.android.identity
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
@@ -7,6 +8,8 @@ import androidx.security.crypto.MasterKey
import java.security.MessageDigest import java.security.MessageDigest
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.util.hexEncodedString import com.bitchat.android.util.hexEncodedString
import androidx.core.content.edit 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_NOISE_FINGERPRINTS = "cached_noise_fingerprints"
private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames" 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_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 // BLE, Wi-Fi Aware, and Noise services each hold their own manager
// instance over the same encrypted preferences. Serialize pin updates // instance over the same encrypted preferences. Serialize pin updates
@@ -317,25 +321,6 @@ class SecureIdentityStateManager {
// MARK: - Authenticated private-media capability pins // 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 { fun isPrivateMediaCapable(fingerprint: String): Boolean {
if (!isValidFingerprint(fingerprint)) return false if (!isValidFingerprint(fingerprint)) return false
return synchronized(privateMediaPinsLock) { return synchronized(privateMediaPinsLock) {
@@ -347,13 +332,64 @@ class SecureIdentityStateManager {
} }
} }
internal fun getPrivateMediaCapabilityPinsForTesting(): Set<String> = /** Persist capabilities and Ed25519 key from a decoded Noise 0x21 proof in one edit. */
synchronized(privateMediaPinsLock) { @SuppressLint("UseKtx")
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) { fun storeAuthenticatedPeerState(
return@synchronized emptySet() 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) // MARK: - Peer ID Rotation Management (removed)
// Android now derives peer ID from the persisted Noise identity fingerprint. // Android now derives peer ID from the persisted Noise identity fingerprint.
@@ -429,12 +465,15 @@ class SecureIdentityStateManager {
/** /**
* Clear all identity data (for panic mode) * Clear all identity data (for panic mode)
*/ */
@SuppressLint("UseKtx")
fun clearIdentityData() { fun clearIdentityData() {
try { try {
synchronized(privateMediaPinsLock) { synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch += 1 privateMediaPinsEpoch += 1
privateMediaPinsEpochAtCreation = privateMediaPinsEpoch 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") Log.w(TAG, "All identity data cleared")
} catch (e: Exception) { } 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 android.util.Log
import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.BitchatMessage 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.protocol.MessagePadding
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.model.IdentityAnnouncement import com.bitchat.android.model.IdentityAnnouncement
@@ -50,18 +52,53 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
private val peerManager = PeerManager() private val peerManager = PeerManager()
private val fragmentManager = FragmentManager() private val fragmentManager = FragmentManager()
private val privateMediaSecurity = PrivateMediaSecurityController( private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
peerInfoProvider = peerManager::getPeerInfo, private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
authenticatedRemoteStaticProvider = encryptionService::getAuthenticatedRemoteStaticKey, private val authenticatedPeerState by lazy {
pinStore = SecurePrivateMediaCapabilityPinStore(context) 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 { private val privateMediaPreparer by lazy {
PrivateMediaTransferPreparer( PrivateMediaTransferPreparer(
senderID = hexStringToByteArray(myPeerID), senderID = hexStringToByteArray(myPeerID),
ttl = MAX_TTL, ttl = MAX_TTL,
policyProvider = privateMediaSecurity::sendPolicy, policyProvider = privateMediaSecurity::sendPolicy,
encrypt = { plaintext, peerID -> encrypt = { plaintext, peerID, authenticatedSession ->
runCatching { encryptionService.encrypt(plaintext, peerID) }.getOrNull() 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, finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict,
fragment = fragmentManager::createFragments fragment = fragmentManager::createFragments
@@ -87,7 +124,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
var delegate: BluetoothMeshDelegate? = null var delegate: BluetoothMeshDelegate? = null
// Coroutines // Coroutines
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var announceJob: Job? = null private var announceJob: Job? = null
// Tracks whether this instance has been terminated via stopServices() // Tracks whether this instance has been terminated via stopServices()
private var terminated = false private var terminated = false
@@ -218,6 +254,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
delegate?.didUpdatePeerList(peerIDs) delegate?.didUpdatePeerList(peerIDs)
} }
override fun onPeerRemoved(peerID: String) { override fun onPeerRemoved(peerID: String) {
authenticatedPeerState.clear(peerID)
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
// Remove from mesh graph topology to prevent routing through stale peers // Remove from mesh graph topology to prevent routing through stale peers
try { com.bitchat.android.services.meshgraph.MeshGraphService.getInstance().removePeer(peerID) } catch (_: Exception) { } 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( override fun onKeyExchangeCompleted(
peerID: String, peerID: String,
authenticatedRemoteStaticKey: ByteArray, authenticatedRemoteStaticKey: ByteArray,
authenticatedSessionToken: ByteArray,
directRelayAddress: String?, directRelayAddress: String?,
ingressLinkID: String? ingressLinkID: String?
) { ) {
authenticatedPeerState.onSessionAuthenticated(
peerID,
authenticatedRemoteStaticKey,
authenticatedSessionToken
)
// Send announcement and cached messages after key exchange // Send announcement and cached messages after key exchange
serviceScope.launch { serviceScope.launch {
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups") 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? { override fun getPeerInfo(peerID: String): PeerInfo? {
return peerManager.getPeerInfo(peerID) return peerManager.getPeerInfo(peerID)
} }
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
} }
// StoreForwardManager delegates // StoreForwardManager delegates
@@ -330,10 +376,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
) )
} }
override fun onVerifiedAnnouncementProcessed(peerID: String) {
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
}
// Packet operations // Packet operations
override fun sendPacket(packet: BitchatPacket) { override fun sendPacket(packet: BitchatPacket) {
// Sign the packet before broadcasting // Sign the packet before broadcasting
@@ -358,13 +400,19 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
return securityManager.encryptForPeer(data, recipientPeerID) 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) return securityManager.decryptFromPeer(encryptedData, senderPeerID)
} }
override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean { override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean {
return encryptionService.verifyEd25519Signature(signature, data, publicKey) return encryptionService.verifyEd25519Signature(signature, data, publicKey)
} }
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
// Noise protocol operations // Noise protocol operations
override fun hasNoiseSession(peerID: String): Boolean { override fun hasNoiseSession(peerID: String): Boolean {
@@ -408,6 +456,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
null null
} }
} }
override fun onAuthenticatedPeerStateReceived(
peerID: String,
state: AuthenticatedPeerState,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
) {
authenticatedPeerState.receive(peerID, state, authenticatedSession)
}
// Message operations // Message operations
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { 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). * 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) { fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) {
try { try {
Log.d(TAG, "📤 sendFileBroadcast: name=${file.fileName}, size=${file.fileSize}") 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") Log.i(TAG, "Private media needs a Noise handshake; initiating without sending")
initiateNoiseHandshake(recipientPeerID) initiateNoiseHandshake(recipientPeerID)
} }
PrivateMediaPreparation.AwaitingPeerState -> Unit
is PrivateMediaPreparation.Rejected -> is PrivateMediaPreparation.Rejected ->
Log.w(TAG, "Private media blocked: ${prepared.reason}") Log.w(TAG, "Private media blocked: ${prepared.reason}")
} }
@@ -855,6 +938,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning) PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning)
PrivateMediaBuildOutcome.NeedsHandshake -> PrivateMediaBuildOutcome.NeedsHandshake ->
PrivateMediaPreparation.NeedsHandshake PrivateMediaPreparation.NeedsHandshake
PrivateMediaBuildOutcome.AwaitingPeerState ->
PrivateMediaPreparation.AwaitingPeerState
is PrivateMediaBuildOutcome.Rejected -> is PrivateMediaBuildOutcome.Rejected ->
PrivateMediaPreparation.Rejected(outcome.reason) PrivateMediaPreparation.Rejected(outcome.reason)
is PrivateMediaBuildOutcome.Ready -> { is PrivateMediaBuildOutcome.Ready -> {
@@ -5,6 +5,8 @@ import android.util.Log
import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatFilePacket 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.IdentityAnnouncement
import com.bitchat.android.model.NoisePayload import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.model.NoisePayloadType
@@ -51,18 +53,52 @@ class MeshCore(
private val peerManager = PeerManager() private val peerManager = PeerManager()
val fragmentManager = FragmentManager() val fragmentManager = FragmentManager()
private val privateMediaSecurity = PrivateMediaSecurityController( private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
peerInfoProvider = peerManager::getPeerInfo, private val authenticatedPeerState by lazy {
authenticatedRemoteStaticProvider = encryptionService::getAuthenticatedRemoteStaticKey, AuthenticatedPeerStateCoordinator(
pinStore = SecurePrivateMediaCapabilityPinStore(context) 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 { private val privateMediaPreparer by lazy {
PrivateMediaTransferPreparer( PrivateMediaTransferPreparer(
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
ttl = maxTtl, ttl = maxTtl,
policyProvider = privateMediaSecurity::sendPolicy, policyProvider = privateMediaSecurity::sendPolicy,
encrypt = { plaintext, peerID -> encrypt = { plaintext, peerID, authenticatedSession ->
runCatching { encryptionService.encrypt(plaintext, peerID) }.getOrNull() 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, finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict,
fragment = fragmentManager::createFragments fragment = fragmentManager::createFragments
@@ -179,6 +215,7 @@ class MeshCore(
} }
override fun onPeerRemoved(peerID: String) { override fun onPeerRemoved(peerID: String) {
authenticatedPeerState.clear(peerID)
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
try { encryptionService.removePeer(peerID) } catch (_: Exception) { } try { encryptionService.removePeer(peerID) } catch (_: Exception) { }
try { peerManager.refreshPeerList() } catch (_: Exception) { } try { peerManager.refreshPeerList() } catch (_: Exception) { }
@@ -189,9 +226,15 @@ class MeshCore(
override fun onKeyExchangeCompleted( override fun onKeyExchangeCompleted(
peerID: String, peerID: String,
authenticatedRemoteStaticKey: ByteArray, authenticatedRemoteStaticKey: ByteArray,
authenticatedSessionToken: ByteArray,
directRelayAddress: String?, directRelayAddress: String?,
ingressLinkID: String? ingressLinkID: String?
) { ) {
authenticatedPeerState.onSessionAuthenticated(
peerID,
authenticatedRemoteStaticKey,
authenticatedSessionToken
)
if (directRelayAddress != null && ingressLinkID != null) { if (directRelayAddress != null && ingressLinkID != null) {
hooks.onDirectNoiseAuthenticated?.invoke( hooks.onDirectNoiseAuthenticated?.invoke(
peerID, peerID,
@@ -222,6 +265,9 @@ class MeshCore(
} }
override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
} }
storeForwardManager.delegate = object : StoreForwardManagerDelegate { storeForwardManager.delegate = object : StoreForwardManagerDelegate {
@@ -285,10 +331,6 @@ class MeshCore(
) )
} }
override fun onVerifiedAnnouncementProcessed(peerID: String) {
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
}
override fun sendPacket(packet: BitchatPacket) { override fun sendPacket(packet: BitchatPacket) {
val signedPacket = signPacketBeforeBroadcast(packet) val signedPacket = signPacketBeforeBroadcast(packet)
dispatchGlobal(RoutedPacket(signedPacket)) dispatchGlobal(RoutedPacket(signedPacket))
@@ -310,7 +352,10 @@ class MeshCore(
return securityManager.encryptForPeer(data, recipientPeerID) 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) return securityManager.decryptFromPeer(encryptedData, senderPeerID)
} }
@@ -318,6 +363,9 @@ class MeshCore(
return encryptionService.verifyEd25519Signature(signature, data, publicKey) return encryptionService.verifyEd25519Signature(signature, data, publicKey)
} }
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
override fun hasNoiseSession(peerID: String): Boolean { override fun hasNoiseSession(peerID: String): Boolean {
return encryptionService.hasEstablishedSession(peerID) 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? { override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return delegate?.decryptChannelMessage(encryptedContent, channel) 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) { fun sendFileBroadcast(file: BitchatFilePacket) {
try { try {
val payload = file.encode() ?: return val payload = file.encode() ?: return
@@ -510,6 +592,7 @@ class MeshCore(
Log.i("MeshCore", "Private media needs a Noise handshake; initiating without sending") Log.i("MeshCore", "Private media needs a Noise handshake; initiating without sending")
initiateNoiseHandshake(recipientPeerID) initiateNoiseHandshake(recipientPeerID)
} }
PrivateMediaPreparation.AwaitingPeerState -> Unit
is PrivateMediaPreparation.Rejected -> is PrivateMediaPreparation.Rejected ->
Log.w("MeshCore", "Private media blocked: ${prepared.reason}") Log.w("MeshCore", "Private media blocked: ${prepared.reason}")
} }
@@ -531,6 +614,8 @@ class MeshCore(
PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning) PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning)
PrivateMediaBuildOutcome.NeedsHandshake -> PrivateMediaBuildOutcome.NeedsHandshake ->
PrivateMediaPreparation.NeedsHandshake PrivateMediaPreparation.NeedsHandshake
PrivateMediaBuildOutcome.AwaitingPeerState ->
PrivateMediaPreparation.AwaitingPeerState
is PrivateMediaBuildOutcome.Rejected -> is PrivateMediaBuildOutcome.Rejected ->
PrivateMediaPreparation.Rejected(outcome.reason) PrivateMediaPreparation.Rejected(outcome.reason)
is PrivateMediaBuildOutcome.Ready -> { is PrivateMediaBuildOutcome.Ready -> {
@@ -13,6 +13,8 @@ interface MeshDelegate {
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {} fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {}
fun didReceiveVerifyResponse(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 decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
fun getNickname(): String? fun getNickname(): String?
fun isFavorite(peerID: String): Boolean fun isFavorite(peerID: String): Boolean
@@ -3,6 +3,7 @@ package com.bitchat.android.mesh
import android.util.Log import android.util.Log
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
@@ -59,11 +60,12 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
try { try {
// Decrypt the message using the Noise service // Decrypt the message using the Noise service
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID) val decryption = delegate?.decryptFromPeer(packet.payload, peerID)
if (decryptedData == null) { if (decryption == null) {
Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake") Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake")
return return
} }
val decryptedData = decryption.plaintext
if (decryptedData.isEmpty()) { if (decryptedData.isEmpty()) {
Log.w(TAG, "Decrypted data is empty from $peerID") 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") 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 -> { com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
// Handle delivery ACK exactly like iOS // Handle delivery ACK exactly like iOS
@@ -248,6 +263,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
return AnnounceHandlingResult.Rejected 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 var verified = true
// Check for existing peer with different noise public key // Check for existing peer with different noise public key
@@ -629,7 +652,6 @@ interface MessageHandlerDelegate {
isVerified: Boolean, isVerified: Boolean,
capabilities: com.bitchat.android.model.PeerCapabilities? = null capabilities: com.bitchat.android.model.PeerCapabilities? = null
): Boolean ): Boolean
fun onVerifiedAnnouncementProcessed(peerID: String) {}
// Packet operations // Packet operations
fun sendPacket(packet: BitchatPacket) fun sendPacket(packet: BitchatPacket)
@@ -639,13 +661,22 @@ interface MessageHandlerDelegate {
// Cryptographic operations // Cryptographic operations
fun verifySignature(packet: BitchatPacket, peerID: String): Boolean fun verifySignature(packet: BitchatPacket, peerID: String): Boolean
fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? 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 verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean
fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null
// Noise protocol operations // Noise protocol operations
fun hasNoiseSession(peerID: String): Boolean fun hasNoiseSession(peerID: String): Boolean
fun initiateNoiseHandshake(peerID: String) fun initiateNoiseHandshake(peerID: String)
fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray?
fun onAuthenticatedPeerStateReceived(
peerID: String,
state: AuthenticatedPeerState,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
) {}
// Message operations // Message operations
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
@@ -1,6 +1,7 @@
package com.bitchat.android.mesh package com.bitchat.android.mesh
import android.util.Log import android.util.Log
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.model.PeerCapabilities
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -162,6 +163,37 @@ class PeerManager {
verifiedAnnouncementNoisePublicKey = noisePublicKey.copyOf() 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( private fun updatePeerInfoInternal(
peerID: String, peerID: String,
nickname: String, nickname: String,
@@ -1,31 +1,15 @@
package com.bitchat.android.mesh package com.bitchat.android.mesh
import android.content.Context
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.model.PeerCapabilities
import java.security.MessageDigest import com.bitchat.android.noise.AuthenticatedNoiseSession
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)
}
}
internal sealed interface PrivateMediaPolicyDecision { internal sealed interface PrivateMediaPolicyDecision {
data object Encrypted : PrivateMediaPolicyDecision data class Encrypted(
val authenticatedSession: AuthenticatedNoiseSession
) : PrivateMediaPolicyDecision
data object RequiresLegacyConsent : PrivateMediaPolicyDecision data object RequiresLegacyConsent : PrivateMediaPolicyDecision
data object NeedsHandshake : PrivateMediaPolicyDecision data object NeedsHandshake : PrivateMediaPolicyDecision
data object AwaitingPeerState : PrivateMediaPolicyDecision
data class Blocked(val reason: String) : PrivateMediaPolicyDecision data class Blocked(val reason: String) : PrivateMediaPolicyDecision
} }
@@ -35,71 +19,46 @@ internal sealed interface PrivateMediaPolicyDecision {
* fingerprint. Announcements alone can never create a pin. * fingerprint. Announcements alone can never create a pin.
*/ */
internal class PrivateMediaSecurityController( internal class PrivateMediaSecurityController(
private val peerInfoProvider: (String) -> PeerInfo?, private val authenticatedSessionProvider: (String) -> AuthenticatedNoiseSession?,
private val authenticatedRemoteStaticProvider: (String) -> ByteArray?, private val peerStateStatusProvider: (
private val pinStore: PrivateMediaCapabilityPinStore 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 { fun sendPolicy(peerID: String): PrivateMediaPolicyDecision {
val remoteStatic = authenticatedRemoteStaticProvider(peerID) val authenticatedSession = authenticatedSessionProvider(peerID)
?.takeIf { it.size == 32 } ?.takeIf { it.remoteStaticKey.size == 32 && it.sessionToken.size == 32 }
?: return PrivateMediaPolicyDecision.NeedsHandshake ?: return PrivateMediaPolicyDecision.NeedsHandshake
val authenticatedFingerprint = fingerprint(remoteStatic)
// Once a fingerprint has authenticated encrypted media support, never return when (val status = peerStateStatusProvider(peerID, authenticatedSession)) {
// downgrade it because a later announce omits or clears the bit. AuthenticatedPeerStateStatus.Awaiting -> PrivateMediaPolicyDecision.AwaitingPeerState
if (pinStore.contains(authenticatedFingerprint)) { is AuthenticatedPeerStateStatus.Proven -> {
return PrivateMediaPolicyDecision.Encrypted if (status.state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) {
} PrivateMediaPolicyDecision.Encrypted(authenticatedSession)
} else if (isPrivateMediaPinned(peerID)) {
val peer = peerInfoProvider(peerID) PrivateMediaPolicyDecision.Blocked(
?: return PrivateMediaPolicyDecision.Blocked( "Encrypted private media was previously pinned, but this session proved no support; send blocked"
"No signature-verified identity announcement is available" )
) } else {
if (!peer.hasVerifiedAnnouncement) { PrivateMediaPolicyDecision.RequiresLegacyConsent
return PrivateMediaPolicyDecision.Blocked( }
"The peer identity announcement has not been verified" }
) AuthenticatedPeerStateStatus.Missing ->
} // A live crypto session can become visible just before its authenticated callback
val announcedStatic = peer.verifiedAnnouncementNoisePublicKey // installs the coordinator generation. Never treat that gap as a watchdog timeout.
?: return PrivateMediaPolicyDecision.Blocked( PrivateMediaPolicyDecision.AwaitingPeerState
"The verified announcement did not contain a Noise identity" AuthenticatedPeerStateStatus.TimedOut -> {
) if (isPrivateMediaPinned(peerID)) {
if (!announcedStatic.contentEquals(remoteStatic)) { PrivateMediaPolicyDecision.Blocked(
return PrivateMediaPolicyDecision.Blocked( "Encrypted private media was previously pinned, but this session did not provide authenticated peer state"
"The authenticated Noise identity does not match the verified announcement" )
) } else {
} // A Noise-capable old client that does not know 0x21 may use explicit one-shot
// legacy consent after the five-second generation watchdog.
return if (peer.capabilities?.contains(PeerCapabilities.PRIVATE_MEDIA) == true) { PrivateMediaPolicyDecision.RequiresLegacyConsent
pinStore.insert(authenticatedFingerprint) }
PrivateMediaPolicyDecision.Encrypted }
} else {
// Both a missing TLV and an explicitly empty bitfield are legacy.
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.BitchatFilePacket
import com.bitchat.android.model.NoisePayload import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@@ -30,6 +31,7 @@ sealed interface PrivateMediaPreparation {
data class Ready(val transfer: PreparedPrivateMediaTransfer) : PrivateMediaPreparation data class Ready(val transfer: PreparedPrivateMediaTransfer) : PrivateMediaPreparation
data class RequiresLegacyConsent(val warning: String) : PrivateMediaPreparation data class RequiresLegacyConsent(val warning: String) : PrivateMediaPreparation
data object NeedsHandshake : PrivateMediaPreparation data object NeedsHandshake : PrivateMediaPreparation
data object AwaitingPeerState : PrivateMediaPreparation
data class Rejected(val reason: String) : 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 Ready(val built: BuiltPrivateMediaTransfer) : PrivateMediaBuildOutcome
data class RequiresLegacyConsent(val warning: String) : PrivateMediaBuildOutcome data class RequiresLegacyConsent(val warning: String) : PrivateMediaBuildOutcome
data object NeedsHandshake : PrivateMediaBuildOutcome data object NeedsHandshake : PrivateMediaBuildOutcome
data object AwaitingPeerState : PrivateMediaBuildOutcome
data class Rejected(val reason: String) : 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. */ /** Builds, routes, signs, and fragments exactly once before UI local echo. */
internal class PrivateMediaTransferPreparer( internal class PrivateMediaTransferPreparer(
private val senderID: ByteArray, private val senderID: ByteArray,
private val ttl: UByte, private val ttl: UByte,
private val policyProvider: (String) -> PrivateMediaPolicyDecision, 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 finalizeRoutedAndSigned: (BitchatPacket) -> BitchatPacket?,
private val fragment: (BitchatPacket, Int) -> List<BitchatPacket>, private val fragment: (BitchatPacket, Int) -> List<BitchatPacket>,
private val now: () -> ULong = { System.currentTimeMillis().toULong() } private val now: () -> ULong = { System.currentTimeMillis().toULong() }
@@ -61,10 +74,24 @@ internal class PrivateMediaTransferPreparer(
recipientID: ByteArray, recipientID: ByteArray,
file: BitchatFilePacket, file: BitchatFilePacket,
allowLegacyFallback: Boolean allowLegacyFallback: Boolean
): PrivateMediaBuildOutcome = prepare(
recipientPeerID,
recipientID,
file,
allowLegacyFallback,
generationRetriesRemaining = 1
)
private fun prepare(
recipientPeerID: String,
recipientID: ByteArray,
file: BitchatFilePacket,
allowLegacyFallback: Boolean,
generationRetriesRemaining: Int
): PrivateMediaBuildOutcome { ): PrivateMediaBuildOutcome {
val policy = policyProvider(recipientPeerID) val policy = policyProvider(recipientPeerID)
val mode = when (policy) { val mode = when (policy) {
PrivateMediaPolicyDecision.Encrypted -> PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 is PrivateMediaPolicyDecision.Encrypted -> PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
PrivateMediaPolicyDecision.RequiresLegacyConsent -> { PrivateMediaPolicyDecision.RequiresLegacyConsent -> {
if (!allowLegacyFallback) { if (!allowLegacyFallback) {
return PrivateMediaBuildOutcome.RequiresLegacyConsent( return PrivateMediaBuildOutcome.RequiresLegacyConsent(
@@ -77,6 +104,8 @@ internal class PrivateMediaTransferPreparer(
} }
PrivateMediaPolicyDecision.NeedsHandshake -> PrivateMediaPolicyDecision.NeedsHandshake ->
return PrivateMediaBuildOutcome.NeedsHandshake return PrivateMediaBuildOutcome.NeedsHandshake
PrivateMediaPolicyDecision.AwaitingPeerState ->
return PrivateMediaBuildOutcome.AwaitingPeerState
is PrivateMediaPolicyDecision.Blocked -> is PrivateMediaPolicyDecision.Blocked ->
return PrivateMediaBuildOutcome.Rejected(policy.reason) return PrivateMediaBuildOutcome.Rejected(policy.reason)
} }
@@ -87,13 +116,34 @@ internal class PrivateMediaTransferPreparer(
val packet = when (mode) { val packet = when (mode) {
PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 -> { PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 -> {
val plaintext = NoisePayload(NoisePayloadType.FILE_TRANSFER, filePayload).encode() val plaintext = NoisePayload(NoisePayloadType.FILE_TRANSFER, filePayload).encode()
val ciphertext = try { val encryption = try {
encrypt(plaintext, recipientPeerID) encrypt(
plaintext,
recipientPeerID,
(policy as PrivateMediaPolicyDecision.Encrypted).authenticatedSession
)
} catch (_: Exception) { } catch (_: Exception) {
null PrivateMediaEncryptionResult.Failed
} ?: return PrivateMediaBuildOutcome.Rejected( }
"The authenticated Noise session could not encrypt this file" 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( BitchatPacket(
version = if (ciphertext.size > 0xFFFF) 2u else 1u, version = if (ciphertext.size > 0xFFFF) 2u else 1u,
type = MessageType.NOISE_ENCRYPTED.value, 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.BitchatPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
import com.bitchat.android.model.RoutedPacket 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 com.bitchat.android.util.toHexString
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.util.* 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") Log.e(TAG, "Bound Noise completion for $peerID omitted its authenticated static key")
return false 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 val isDirectIngress = packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
Log.d(TAG, "✅ Noise handshake completed with $peerID") Log.d(TAG, "✅ Noise handshake completed with $peerID")
delegate?.onKeyExchangeCompleted( delegate?.onKeyExchangeCompleted(
peerID = peerID, peerID = peerID,
authenticatedRemoteStaticKey = authenticatedRemoteStaticKey, authenticatedRemoteStaticKey = authenticatedRemoteStaticKey,
authenticatedSessionToken = authenticatedSessionToken,
directRelayAddress = routed.relayAddress.takeIf { isDirectIngress }, directRelayAddress = routed.relayAddress.takeIf { isDirectIngress },
ingressLinkID = routed.ingressLinkID.takeIf { isDirectIngress } ingressLinkID = routed.ingressLinkID.takeIf { isDirectIngress }
) )
@@ -187,13 +197,24 @@ class SecurityManager(private val encryptionService: EncryptionService, private
null 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 * Decrypt payload from specific peer
*/ */
fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? { fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): NoiseDecryptionResult? {
return try { return try {
encryptionService.decrypt(encryptedData, senderPeerID) encryptionService.decryptWithSession(encryptedData, senderPeerID)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to decrypt from $senderPeerID: ${e.message}") Log.e(TAG, "Failed to decrypt from $senderPeerID: ${e.message}")
null null
@@ -250,6 +271,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private
return false 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) val existingPeer = delegate?.getPeerInfo(peerID)
if ( if (
existingPeer?.noisePublicKey != null && existingPeer?.noisePublicKey != null &&
@@ -429,9 +458,11 @@ interface SecurityManagerDelegate {
fun onKeyExchangeCompleted( fun onKeyExchangeCompleted(
peerID: String, peerID: String,
authenticatedRemoteStaticKey: ByteArray, authenticatedRemoteStaticKey: ByteArray,
authenticatedSessionToken: ByteArray,
directRelayAddress: String?, directRelayAddress: String?,
ingressLinkID: String? ingressLinkID: String?
) )
fun sendHandshakeResponse(peerID: String, response: ByteArray) fun sendHandshakeResponse(peerID: String, response: ByteArray)
fun getPeerInfo(peerID: String): PeerInfo? // NEW: For signature verification 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) delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
} }
override fun didResolvePrivateMediaPolicy(peerID: String) {
delegate?.didResolvePrivateMediaPolicy(peerID)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return delegate?.decryptChannelMessage(encryptedContent, channel) 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 DELIVERED(0x03u), // Message was delivered
VERIFY_CHALLENGE(0x10u), // Verification challenge VERIFY_CHALLENGE(0x10u), // Verification challenge
VERIFY_RESPONSE(0x11u), // Verification response 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 { companion object {
@@ -149,6 +149,15 @@ class NoiseEncryptionService(private val context: Context) {
fun getPeerPublicKeyData(peerID: String): ByteArray? { fun getPeerPublicKeyData(peerID: String): ByteArray? {
return sessionManager.getRemoteStaticKey(peerID) 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) * Clear persistent identity (for panic mode)
@@ -247,6 +256,13 @@ class NoiseEncryptionService(private val context: Context) {
null 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 * Decrypt data from a specific peer using established Noise session
@@ -264,6 +280,14 @@ class NoiseEncryptionService(private val context: Context) {
null 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 // MARK: - Peer Management
@@ -474,7 +474,10 @@ class NoiseSession(
receiveCipher = cipherPair.getReceiver() receiveCipher = cipherPair.getReceiver()
// Extract handshake hash for channel binding // 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 // Clean up handshake state
activeHandshake.destroy() activeHandshake.destroy()
@@ -7,7 +7,36 @@ data class NoiseHandshakeProcessingResult(
val response: ByteArray?, val response: ByteArray?,
val establishedNow: Boolean, val establishedNow: Boolean,
/** The bound remote static key only when this exact call completed authentication. */ /** 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 TAG = "NoiseSessionManager"
private const val HANDSHAKE_TIMEOUT_MS = 20_000L private const val HANDSHAKE_TIMEOUT_MS = 20_000L
private const val HANDSHAKE_MESSAGE_1_SIZE = 32 private const val HANDSHAKE_MESSAGE_1_SIZE = 32
private const val SESSION_TOKEN_SIZE = 32
} }
private val sessions = ConcurrentHashMap<String, NoiseSession>() private val sessions = ConcurrentHashMap<String, NoiseSession>()
@@ -130,6 +160,7 @@ class NoiseSessionManager(
var activeSession: NoiseSession? = null var activeSession: NoiseSession? = null
var isReplacementCandidate = false var isReplacementCandidate = false
var establishedRemoteKey: ByteArray? = null var establishedRemoteKey: ByteArray? = null
var establishedSessionToken: ByteArray? = null
var response: ByteArray? = null var response: ByteArray? = null
try { try {
@@ -204,6 +235,10 @@ class NoiseSessionManager(
throw NoiseSessionError.PeerIdentityMismatch(peerID, derivedPeerID) 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) { if (isReplacementCandidate) {
responderCandidates.remove(peerID, session) responderCandidates.remove(peerID, session)
val previous = sessions.put(peerID, session) val previous = sessions.put(peerID, session)
@@ -211,6 +246,7 @@ class NoiseSessionManager(
} }
establishedRemoteKey = remoteStaticKey establishedRemoteKey = remoteStaticKey
establishedSessionToken = sessionToken
Log.d(TAG, "✅ Session ESTABLISHED with bound identity $peerID") Log.d(TAG, "✅ Session ESTABLISHED with bound identity $peerID")
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -232,7 +268,8 @@ class NoiseSessionManager(
return NoiseHandshakeProcessingResult( return NoiseHandshakeProcessingResult(
response = response, response = response,
establishedNow = establishedRemoteKey != null, establishedNow = establishedRemoteKey != null,
authenticatedRemoteStaticKey = establishedRemoteKey?.clone() authenticatedRemoteStaticKey = establishedRemoteKey?.clone(),
authenticatedSessionToken = establishedSessionToken?.clone()
) )
} }
@@ -252,6 +289,7 @@ class NoiseSessionManager(
/** /**
* SIMPLIFIED: Encrypt data * SIMPLIFIED: Encrypt data
*/ */
@Synchronized
fun encrypt(data: ByteArray, peerID: String): ByteArray { fun encrypt(data: ByteArray, peerID: String): ByteArray {
val session = getSession(peerID) ?: throw IllegalStateException("No session found for $peerID") val session = getSession(peerID) ?: throw IllegalStateException("No session found for $peerID")
if (!session.isEstablished()) { if (!session.isEstablished()) {
@@ -259,11 +297,29 @@ class NoiseSessionManager(
} }
return session.encrypt(data) 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 * 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) val session = getSession(peerID)
if (session == null) { if (session == null) {
Log.e(TAG, "No session found for $peerID when trying to decrypt") 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") Log.e(TAG, "Session not established with $peerID when trying to decrypt")
throw IllegalStateException("Session not established with $peerID") 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) * Get remote static public key for a peer (if session established)
*/ */
fun getRemoteStaticKey(peerID: String): ByteArray? { fun getRemoteStaticKey(peerID: String): ByteArray? =
return getSession(peerID)?.getRemoteStaticPublicKey() 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) * Get handshake hash for channel binding (if session established)
*/ */
fun getHandshakeHash(peerID: String): ByteArray? { 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 InvalidState : NoiseSessionError("Session in invalid state")
object HandshakeFailed : NoiseSessionError("Handshake failed") object HandshakeFailed : NoiseSessionError("Handshake failed")
object AlreadyEstablished : NoiseSessionError("Session already established") object AlreadyEstablished : NoiseSessionError("Session already established")
object SessionGenerationChanged : NoiseSessionError("Noise session generation changed")
class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError( class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError(
"Authenticated Noise key derives to ${derivedPeerID ?: "invalid"}, not claimed peer $claimedPeerID" "Authenticated Noise key derives to ${derivedPeerID ?: "invalid"}, not claimed peer $claimedPeerID"
) )
@@ -191,7 +191,8 @@ class NostrDirectMessageHandler(
} }
} }
NoisePayloadType.VERIFY_CHALLENGE, 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 val verifiedFingerprints = verificationHandler.verifiedFingerprints
// Media file sending manager // 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 // Delegate handler for mesh callbacks
private val meshDelegateHandler = MeshDelegateHandler( private val meshDelegateHandler = MeshDelegateHandler(
@@ -931,6 +936,10 @@ class ChatViewModel(
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
verificationHandler.didReceiveVerifyResponse(peerID, payload) verificationHandler.didReceiveVerifyResponse(peerID, payload)
} }
override fun didResolvePrivateMediaPolicy(peerID: String) {
mediaSendingManager.retryPendingPrivateMedia(peerID)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel) return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel)
@@ -12,6 +12,9 @@ import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
data class LegacyPrivateMediaConsentRequest( data class LegacyPrivateMediaConsentRequest(
val requestId: String, val requestId: String,
@@ -28,6 +31,7 @@ class MediaSendingManager(
private val state: ChatState, private val state: ChatState,
private val messageManager: MessageManager, private val messageManager: MessageManager,
private val channelManager: ChannelManager, private val channelManager: ChannelManager,
private val scope: CoroutineScope,
private val getMeshService: () -> MeshService private val getMeshService: () -> MeshService
) { ) {
// Helper to get current mesh service (may change after panic clear) // Helper to get current mesh service (may change after panic clear)
@@ -36,6 +40,7 @@ class MediaSendingManager(
companion object { companion object {
private const val TAG = "MediaSendingManager" 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 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 // Track in-flight transfer progress: transferId -> messageId and reverse
@@ -57,6 +62,21 @@ class MediaSendingManager(
private var pendingPrivateMedia: PendingPrivateMedia? = null 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) * 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)}") 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( val pending = PendingAutomaticPrivateMedia(
recipientPeerID = toPeerID, requestId = UUID.randomUUID().toString(),
file = filePacket, peerID = toPeerID,
filePacket = filePacket,
filePath = filePath,
messageType = messageType,
transferId = transferId, transferId = transferId,
allowLegacyFallback = false allowLegacyFallback = false
)) { )
is PrivateMediaPreparation.Ready -> commitPreparedPrivateFile( if (!reserveAutomaticPending(pending)) {
preparation = preparation, addPrivateMediaSystemMessage(
toPeerID = toPeerID, toPeerID,
filePath = filePath, "Private media was not sent because another secure media send is still pending."
messageType = messageType,
transferId = transferId
) )
return
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}")
} }
evaluateAutomaticPending(pending)
} }
/** /**
@@ -277,32 +259,23 @@ class MediaSendingManager(
*/ */
fun approveLegacyPrivateMedia(requestId: String) { fun approveLegacyPrivateMedia(requestId: String) {
val pending = consumePendingConsent(requestId) ?: return val pending = consumePendingConsent(requestId) ?: return
when (val preparation = meshService.prepareFilePrivate( val automatic = PendingAutomaticPrivateMedia(
recipientPeerID = pending.peerID, requestId = UUID.randomUUID().toString(),
file = pending.filePacket, peerID = pending.peerID,
filePacket = pending.filePacket,
filePath = pending.filePath,
messageType = pending.messageType,
transferId = pending.transferId, transferId = pending.transferId,
allowLegacyFallback = true allowLegacyFallback = true
)) { )
is PrivateMediaPreparation.Ready -> commitPreparedPrivateFile( if (!reserveAutomaticPending(automatic)) {
preparation, addPrivateMediaSystemMessage(
pending.peerID, pending.peerID,
pending.filePath, "Private media was not sent because another secure media send is still pending."
pending.messageType,
pending.transferId
) )
is PrivateMediaPreparation.RequiresLegacyConsent -> return
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}")
} }
evaluateAutomaticPending(automatic)
} }
fun cancelLegacyPrivateMedia(requestId: String) { fun cancelLegacyPrivateMedia(requestId: String) {
@@ -312,10 +285,221 @@ class MediaSendingManager(
fun clearPendingPrivateMediaConsent() { fun clearPendingPrivateMediaConsent() {
synchronized(pendingConsentLock) { synchronized(pendingConsentLock) {
pendingPrivateMedia = null pendingPrivateMedia = null
pendingAutomaticPrivateMedia = null
evaluatingAutomaticRequestId = null
automaticRetryRequestedFor = null
pendingAutomaticTimeoutRequestId = null
_legacyPrivateMediaConsent.value = 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? { private fun consumePendingConsent(requestId: String): PendingPrivateMedia? {
return synchronized(pendingConsentLock) { return synchronized(pendingConsentLock) {
val pending = pendingPrivateMedia val pending = pendingPrivateMedia
@@ -369,6 +553,10 @@ class MediaSendingManager(
messageTransferMap.remove(msg.id) messageTransferMap.remove(msg.id)
} }
Log.w(TAG, "Prepared private-media commit failed; local echo rolled back") 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 return
} }
Log.d(TAG, "✅ Private media committed using ${preparation.transfer.wireMode}") Log.d(TAG, "✅ Private media committed using ${preparation.transfer.wireMode}")
@@ -1,6 +1,10 @@
package com.bitchat.android.identity package com.bitchat.android.identity
import android.content.Context 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.After
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -33,18 +37,37 @@ class PrivateMediaCapabilityPinPersistenceTest {
} }
@Test @Test
fun `capability pin persists and panic identity wipe removes it`() { fun `authenticated Ed key and capabilities persist rotate and clear atomically`() {
manager.markPrivateMediaCapable(fingerprint) 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) 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)) assertTrue(reloaded.isPrivateMediaCapable(fingerprint))
reloaded.clearIdentityData() reloaded.clearIdentityData()
// Simulate an old BLE/Wi-Fi controller finishing a pre-panic callback assertFalse(manager.storeAuthenticatedPeerState(
// after another manager performed the wipe. fingerprint,
manager.markPrivateMediaCapable(fingerprint) AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, firstKey)
))
val afterPanic = SecureIdentityStateManager(prefs, testOnly = true) val afterPanic = SecureIdentityStateManager(prefs, testOnly = true)
assertEquals(null, afterPanic.getAuthenticatedPeerState(fingerprint))
assertFalse(afterPanic.isPrivateMediaCapable(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 android.os.Build
import com.bitchat.android.model.IdentityAnnouncement import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.BitchatFilePacket 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.PeerCapabilities
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoisePeerIdentity 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.BitchatPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.protocol.SpecialRecipients
@@ -38,6 +43,10 @@ class MessageHandlerTest {
private val myPeerID = "1111222233334444" private val myPeerID = "1111222233334444"
private val noiseKey = ByteArray(32) { 0x0B } private val noiseKey = ByteArray(32) { 0x0B }
private val peerID = NoisePeerIdentity.derivePeerID(noiseKey)!! private val peerID = NoisePeerIdentity.derivePeerID(noiseKey)!!
private val authenticatedSession = AuthenticatedNoiseSession(
noiseKey,
ByteArray(32) { 0x5D }
)
private val nickname = "peer" private val nickname = "peer"
private val signingKey = ByteArray(32) { 0x0A } private val signingKey = ByteArray(32) { 0x0A }
private val signature = ByteArray(64) { 1 } private val signature = ByteArray(64) { 1 }
@@ -216,7 +225,9 @@ class MessageHandlerTest {
) )
val prereleasePlaintext = byteArrayOf(0x09) + file.encode()!! val prereleasePlaintext = byteArrayOf(0x09) + file.encode()!!
val ciphertext = byteArrayOf(0x41, 0x42, 0x43) 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.getPeerNickname(peerID)).thenReturn(nickname)
whenever(delegate.getMyNickname()).thenReturn("me") whenever(delegate.getMyNickname()).thenReturn("me")
whenever(delegate.encryptForPeer(any(), eq(peerID))).thenReturn(byteArrayOf(0x55)) 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 @Test
fun `first self-signed announce cannot claim an ID derived from another Noise key`() = runBlocking { fun `first self-signed announce cannot claim an ID derived from another Noise key`() = runBlocking {
val attackerNoiseKey = ByteArray(32) { 0x6B } val attackerNoiseKey = ByteArray(32) { 0x6B }
@@ -314,6 +360,20 @@ class MessageHandlerTest {
Unit 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( private fun announcePacket(
ageMs: Long, ageMs: Long,
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(), ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(),
@@ -1,144 +1,69 @@
package com.bitchat.android.mesh package com.bitchat.android.mesh
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.noise.AuthenticatedNoiseSession
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class PrivateMediaSecurityTest { 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 peerID = "0011223344556677"
private val remoteStatic = ByteArray(32) { (it + 1).toByte() } private var authenticatedSession: AuthenticatedNoiseSession? = AuthenticatedNoiseSession(
private var peerInfo: PeerInfo? = null ByteArray(32) { (it + 1).toByte() },
private var authenticatedRemoteStatic: ByteArray? = null ByteArray(32) { 0x51 }
private val pins = MemoryPins() )
private var status: AuthenticatedPeerStateStatus = AuthenticatedPeerStateStatus.Awaiting
private var pinned = false
private val controller = PrivateMediaSecurityController( private val controller = PrivateMediaSecurityController(
peerInfoProvider = { peerInfo }, authenticatedSessionProvider = { authenticatedSession },
authenticatedRemoteStaticProvider = { authenticatedRemoteStatic?.copyOf() }, peerStateStatusProvider = { _, _ -> status },
pinStore = pins isPrivateMediaPinned = { pinned }
) )
@Test @Test
fun `announce before handshake promotes only after authenticated key arrives`() { fun `live session waits for fresh generation proof`() {
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA) assertEquals(PrivateMediaPolicyDecision.AwaitingPeerState, controller.sendPolicy(peerID))
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)
} }
@Test @Test
fun `handshake before announce promotes only after verified announce arrives`() { fun `valid private-media proof enables encrypted wire`() {
authenticatedRemoteStatic = remoteStatic status = AuthenticatedPeerStateStatus.Proven(
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 1 })
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 }
) )
assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Encrypted)
}
assertFalse(controller.refreshAuthenticatedCapability(peerID)) @Test
assertTrue(pins.pins.isEmpty()) 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) assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked)
}
// A normal peer refresh may carry a new current key, but it cannot @Test
// transfer capability trust away from the key in the signed announce. fun `no-proof timeout permits old-client consent but blocks pinned downgrade`() {
peerInfo = peer( status = AuthenticatedPeerStateStatus.TimedOut
capabilities = PeerCapabilities.PRIVATE_MEDIA, assertEquals(PrivateMediaPolicyDecision.RequiresLegacyConsent, controller.sendPolicy(peerID))
noiseKey = remoteStatic,
verifiedAnnouncementNoiseKey = ByteArray(32) { 0x55 } pinned = true
)
assertFalse(controller.refreshAuthenticatedCapability(peerID))
assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked) assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked)
peerInfo = peer(
capabilities = PeerCapabilities.PRIVATE_MEDIA,
hasVerifiedAnnouncement = false
)
assertFalse(controller.refreshAuthenticatedCapability(peerID))
assertTrue(pins.pins.isEmpty())
} }
@Test @Test
fun `signed absent and explicit empty capabilities both require one shot consent`() { fun `live session missing coordinator generation waits and never unlocks legacy`() {
authenticatedRemoteStatic = remoteStatic status = AuthenticatedPeerStateStatus.Missing
assertEquals(PrivateMediaPolicyDecision.AwaitingPeerState, controller.sendPolicy(peerID))
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())
} }
@Test @Test
fun `pin is HSTS and prevents later capability downgrade`() { fun `pin never bypasses requirement for live authenticated session`() {
authenticatedRemoteStatic = remoteStatic pinned = true
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA) authenticatedSession = null
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
assertEquals(PrivateMediaPolicyDecision.NeedsHandshake, controller.sendPolicy(peerID)) 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.model.NoisePayloadType
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
import com.bitchat.android.noise.AuthenticatedNoiseSession
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
@@ -18,6 +19,10 @@ import java.util.Random
class PrivateMediaTransferPreparerTest { class PrivateMediaTransferPreparerTest {
private val senderID = hex("0011223344556677") private val senderID = hex("0011223344556677")
private val recipientID = hex("8877665544332211") private val recipientID = hex("8877665544332211")
private val authenticatedSession = AuthenticatedNoiseSession(
ByteArray(32) { 0x21 },
ByteArray(32) { 0x22 }
)
private val fragmentManagers = mutableListOf<FragmentManager>() private val fragmentManagers = mutableListOf<FragmentManager>()
@After @After
@@ -29,10 +34,10 @@ class PrivateMediaTransferPreparerTest {
fun `encrypted mode emits deployed Noise 0x20 and signs before fragmentation`() { fun `encrypted mode emits deployed Noise 0x20 and signs before fragmentation`() {
var encryptedPlaintext: ByteArray? = null var encryptedPlaintext: ByteArray? = null
val preparer = preparer( val preparer = preparer(
policy = PrivateMediaPolicyDecision.Encrypted, policy = PrivateMediaPolicyDecision.Encrypted(authenticatedSession),
encrypt = { bytes, _ -> encrypt = { bytes, _, _ ->
encryptedPlaintext = bytes encryptedPlaintext = bytes
byteArrayOf(0x41) + bytes PrivateMediaEncryptionResult.Success(byteArrayOf(0x41) + bytes)
} }
) )
@@ -101,9 +106,9 @@ class PrivateMediaTransferPreparerTest {
senderID = senderID, senderID = senderID,
ttl = 7u, ttl = 7u,
policyProvider = { PrivateMediaPolicyDecision.NeedsHandshake }, policyProvider = { PrivateMediaPolicyDecision.NeedsHandshake },
encrypt = { _, _ -> encrypt = { _, _, _ ->
encrypted = true encrypted = true
byteArrayOf(1) PrivateMediaEncryptionResult.Success(byteArrayOf(1))
}, },
finalizeRoutedAndSigned = { finalizeRoutedAndSigned = {
finalized = true finalized = true
@@ -123,6 +128,38 @@ class PrivateMediaTransferPreparerTest {
assertTrue(!fragmented) 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 @Test
fun `no route accepts 256 final fragments and rejects 257`() { fun `no route accepts 256 final fragments and rejects 257`() {
assertExactBoundary(route = null) 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>?) { private fun assertExactBoundary(route: List<ByteArray>?) {
val randomContent = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) } val randomContent = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) }
val preparer = preparer( val preparer = preparer(
policy = PrivateMediaPolicyDecision.Encrypted, policy = PrivateMediaPolicyDecision.Encrypted(authenticatedSession),
finalizer = { packet -> finalizer = { packet ->
packet.copy( packet.copy(
version = if (route == null) packet.version else 2u, version = if (route == null) packet.version else 2u,
@@ -180,7 +269,13 @@ class PrivateMediaTransferPreparerTest {
private fun preparer( private fun preparer(
policy: PrivateMediaPolicyDecision, 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 -> finalizer: (BitchatPacket) -> BitchatPacket? = { packet ->
packet.copy(signature = ByteArray(64) { 0x33 }) packet.copy(signature = ByteArray(64) { 0x33 })
} }
@@ -34,6 +34,7 @@ class SecurityManagerTest {
// Key pairs (using dummy bytes for mock verification) // Key pairs (using dummy bytes for mock verification)
private val otherSigningKey = ByteArray(32) { 0xA } private val otherSigningKey = ByteArray(32) { 0xA }
private val otherNoiseKey = ByteArray(32) { 0xB } private val otherNoiseKey = ByteArray(32) { 0xB }
private val sessionToken = ByteArray(32) { 0x5C }
private val unknownPeerID = NoisePeerIdentity.derivePeerID(otherNoiseKey)!! private val unknownPeerID = NoisePeerIdentity.derivePeerID(otherNoiseKey)!!
private val dummyPayload = "Hello World".toByteArray() private val dummyPayload = "Hello World".toByteArray()
@@ -344,6 +345,21 @@ class SecurityManagerTest {
assertFalse(securityManager.validatePacket(packet, otherPeerID)) 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 @Test
fun `validatePacket - ignores own packets`() { fun `validatePacket - ignores own packets`() {
val packet = BitchatPacket( val packet = BitchatPacket(
@@ -420,7 +436,9 @@ class SecurityManagerTest {
assertTrue(accepted) assertTrue(accepted)
assertTrue(fakeEncryptionService.removePeerCalls == 0) assertTrue(fakeEncryptionService.removePeerCalls == 0)
verify(mockDelegate).sendHandshakeResponse(otherPeerID, response) verify(mockDelegate).sendHandshakeResponse(otherPeerID, response)
verify(mockDelegate, never()).onKeyExchangeCompleted(any(), any(), anyOrNull(), anyOrNull()) verify(mockDelegate, never()).onKeyExchangeCompleted(
any(), any(), any(), anyOrNull(), anyOrNull()
)
} }
@Test @Test
@@ -434,19 +452,23 @@ class SecurityManagerTest {
assertFalse(securityManager.handleNoiseHandshake(routed)) assertFalse(securityManager.handleNoiseHandshake(routed))
assertTrue(fakeEncryptionService.removePeerCalls == 0) assertTrue(fakeEncryptionService.removePeerCalls == 0)
verify(mockDelegate, never()).sendHandshakeResponse(any(), any()) 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.handshakeError = null
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult( fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
response = null, response = null,
establishedNow = true, establishedNow = true,
authenticatedRemoteStaticKey = otherNoiseKey authenticatedRemoteStaticKey = otherNoiseKey,
authenticatedSessionToken = sessionToken
) )
assertTrue("Failed frames must not poison the processed-exchange cache", securityManager.handleNoiseHandshake(routed)) assertTrue("Failed frames must not poison the processed-exchange cache", securityManager.handleNoiseHandshake(routed))
assertTrue(fakeEncryptionService.handshakeCalls == 2) assertTrue(fakeEncryptionService.handshakeCalls == 2)
verify(mockDelegate).onKeyExchangeCompleted( verify(mockDelegate).onKeyExchangeCompleted(
otherPeerID, otherPeerID,
otherNoiseKey, otherNoiseKey,
sessionToken,
"direct-link", "direct-link",
"direct-link-token" "direct-link-token"
) )
@@ -457,7 +479,8 @@ class SecurityManagerTest {
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult( fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
response = null, response = null,
establishedNow = true, establishedNow = true,
authenticatedRemoteStaticKey = otherNoiseKey authenticatedRemoteStaticKey = otherNoiseKey,
authenticatedSessionToken = sessionToken
) )
val routed = handshakePacket(byteArrayOf(0x51, 0x52, 0x53)) val routed = handshakePacket(byteArrayOf(0x51, 0x52, 0x53))
@@ -466,6 +489,7 @@ class SecurityManagerTest {
verify(mockDelegate, times(1)).onKeyExchangeCompleted( verify(mockDelegate, times(1)).onKeyExchangeCompleted(
otherPeerID, otherPeerID,
otherNoiseKey, otherNoiseKey,
sessionToken,
"direct-link", "direct-link",
"direct-link-token" "direct-link-token"
) )
@@ -478,7 +502,8 @@ class SecurityManagerTest {
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult( fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
response = null, response = null,
establishedNow = true, establishedNow = true,
authenticatedRemoteStaticKey = otherNoiseKey authenticatedRemoteStaticKey = otherNoiseKey,
authenticatedSessionToken = sessionToken
) )
val routed = handshakePacket( val routed = handshakePacket(
payload = byteArrayOf(0x61, 0x62, 0x63), payload = byteArrayOf(0x61, 0x62, 0x63),
@@ -486,7 +511,13 @@ class SecurityManagerTest {
) )
assertTrue(securityManager.handleNoiseHandshake(routed)) 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) { 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.assertTrue
import org.junit.Assert.fail import org.junit.Assert.fail
import org.junit.Test 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 { class NoiseSessionManagerIdentityBindingTest {
private data class TestIdentity( private data class TestIdentity(
@@ -40,8 +44,12 @@ class NoiseSessionManagerIdentityBindingTest {
assertArrayEquals(alice.publicKey, bobManager.getRemoteStaticKey(alice.peerID)) assertArrayEquals(alice.publicKey, bobManager.getRemoteStaticKey(alice.peerID))
val plaintext = "bound transport".toByteArray() val plaintext = "bound transport".toByteArray()
val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) val aliceSession = aliceManager.getAuthenticatedSession(bob.peerID)!!
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.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 @Test
@@ -145,6 +153,7 @@ class NoiseSessionManagerIdentityBindingTest {
completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID) completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID)
val originalSession = aliceManager.getSession(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. // Simulate Bob restarting with the same persistent static identity and no session state.
val restartedBobManager = manager(bob) val restartedBobManager = manager(bob)
@@ -157,12 +166,98 @@ class NoiseSessionManagerIdentityBindingTest {
assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID)) assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID))
assertTrue(aliceAuthenticatedCallbacks == 2) 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 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)) 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 @Test
fun `peer ID derivation rejects malformed keys and non-wire claims`() { fun `peer ID derivation rejects malformed keys and non-wire claims`() {
val peer = identity() val peer = identity()
@@ -44,6 +44,7 @@ class MediaSendingManagerMigrationTest {
state, state,
MessageManager(state), MessageManager(state),
mock(), mock(),
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
getMeshService = { mesh } getMeshService = { mesh }
) )
file = kotlin.io.path.createTempFile("private-media", ".jpg").toFile().apply { file = kotlin.io.path.createTempFile("private-media", ".jpg").toFile().apply {
@@ -57,28 +58,112 @@ class MediaSendingManagerMigrationTest {
} }
@Test @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))) whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.Rejected("too many fragments")) .thenReturn(PrivateMediaPreparation.Rejected("too many fragments"))
manager.sendImageNote(peerID, null, file.absolutePath) 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) assertEquals(null, manager.legacyPrivateMediaConsent.value)
verify(mesh, never()).sendFilePrivate(any(), any()) verify(mesh, never()).sendFilePrivate(any(), any())
} }
@Test @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))) whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.NeedsHandshake) .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) manager.sendImageNote(peerID, null, file.absolutePath)
verify(mesh, times(1)).initiateNoiseHandshake(peerID) verify(mesh, times(1)).initiateNoiseHandshake(peerID)
verify(mesh, never()).sendFilePrivate(any(), any())
assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
assertEquals(null, manager.legacyPrivateMediaConsent.value) 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 @Test
+7
View File
@@ -38,6 +38,13 @@ The same authenticated callback restores any existing Noise-key-to-Nostr
relationship under the canonical 16-hex mesh ID; unproven announcements never relationship under the canonical 16-hex mesh ID; unproven announcements never
write that routing index. write that routing index.
Generation-sensitive consumers use the 32-byte Noise handshake hash as a local
session token. The hash is cloned before handshake-state zeroization, and
session lookup, decrypt, expected-token encrypt, and leased identity mutation
share the Noise manager lock. A same-static replacement therefore cannot reuse
the previous generation's proof or destroy a session while a bound mutation is
in progress.
## Remaining TOFU boundary ## Remaining TOFU boundary
The first public announcement is still self-signed trust-on-first-use. An The first public announcement is still self-signed trust-on-first-use. An
+85 -34
View File
@@ -21,7 +21,7 @@ build that emitted it has expired and the project's minimum-supported-client
policy excludes those builds. Track that release criterion explicitly; do not policy excludes those builds. Track that release criterion explicitly; do not
remove the alias on an arbitrary calendar date. remove the alias on an arbitrary calendar date.
## Capability announcement ## Discovery hint
Identity announcement TLV `0x05` is a minimal little-endian bitfield. Bit 8 Identity announcement TLV `0x05` is a minimal little-endian bitfield. Bit 8
means the peer implements private-media v1, so its exact encoding is: means the peer implements private-media v1, so its exact encoding is:
@@ -35,41 +35,82 @@ means the peer implements private-media v1, so its exact encoding is:
Current Android builds include this TLV in broadcast and peer-directed Current Android builds include this TLV in broadcast and peer-directed
announcements over both BLE and Wi-Fi Aware. Older clients safely skip the announcements over both BLE and Wi-Fi Aware. Older clients safely skip the
unknown TLV. Its absence, or a present TLV with bit 8 clear, describes a legacy unknown TLV. Its absence, or a present TLV with bit 8 clear, does not invalidate
client and does not invalidate the announcement. the announcement.
Decoders retain unknown low-64-bit capability bits and unknown announcement Decoders retain unknown low-64-bit capability bits and unknown announcement
TLVs so a decode/re-encode cycle does not erase newer extensions. TLVs so a decode/re-encode cycle does not erase newer extensions.
## Authenticated capability pinning The announcement bit is discovery metadata only. A self-signed announcement
does not prove possession of its public Noise key, so it never authorizes
encrypted media, creates a capability pin, or satisfies a pending send.
The capability bit is security-sensitive and is not trusted just because an ## Authenticated peer state (`0x21`)
announcement is self-signed. A client may promote bit 8 only after both of
these checks succeed, in either arrival order:
1. The announcement has a valid Ed25519 signature using its advertised Every newly authenticated Noise generation, including a rekey with the same
signing key. static key, exchanges a fresh peer-state proof. Its decrypted Noise payload
2. Its advertised Noise static key exactly matches the remote static key type is `0x21`, followed by this canonical byte sequence:
authenticated by the live Noise handshake.
After promotion, store an HSTS-style pin keyed by the SHA-256 fingerprint of ```text
that authenticated Noise static key. The pin is persistent, encrypted at rest, 01 version
and cleared by panic wipe. Never derive it from a peer-ID cache, 01 <len 1...8> <value> capabilities, minimal little-endian
`PeerFingerprintManager`, an unverified announcement, or a Noise key that does 02 20 <32 bytes> Ed25519 signing public key
not match the signed announcement. ```
A pin prevents a later missing or cleared capability bit from downgrading the Both known TLVs are required exactly once. Decoders reject an unknown version,
same authenticated identity. It never substitutes for a live authenticated truncated TLV, duplicate known TLV, a capability length outside `1...8`, a
Noise session when sending. non-minimal capability value, or an Ed25519 key whose length is not 32. Unknown
TLVs are skipped for forward compatibility, and the two known TLVs may arrive
in either order.
Each endpoint sends `0x21` when the generation authenticates and echoes its
local state at most once after accepting the peer's first valid proof for that
generation. Repeated identical proofs are idempotent; a different second proof
cannot replace the first within that generation. A five-second generation
watchdog distinguishes an older client that ignores `0x21` from a new client
that supplied a proof. Persisted state from a previous connection never
satisfies the fresh-generation watchdog.
Locally, the Noise handshake hash is the generation token. Decryption returns
that token with the plaintext, coordinator mutations hold a lease on that exact
session, and private-media encryption accepts the policy decision only while
the same token remains active. A same-static rekey therefore cannot reuse an
older proof or race policy into encrypting on an unproved generation.
The proof is bound by the Noise channel to the exact authenticated 32-byte
remote static key and canonical peer ID. Store its capabilities and 32-byte
Ed25519 key under the SHA-256 fingerprint of that static key in encrypted
identity state. A proof with bit 8 creates an HSTS-style private-media pin; a
later no-bit proof does not erase that history. Panic wipe clears both records
and prevents an in-flight pre-wipe controller from restoring them.
The persisted Ed25519 key is consulted before accepting later announcements.
This lets a valid proof recover from a copied-static, wrong-Ed preannouncement,
while preventing that preannouncement from winning again after restart. A
fresh proof in a later Noise generation may intentionally rotate the Ed25519
key; an announcement by itself cannot.
## Mixed-client send policy ## Mixed-client send policy
- No live authenticated Noise remote-static key: emit no media or local echo, - No live authenticated Noise remote-static key: retain the first send intent,
initiate one Noise handshake, and require the user to retry after it completes. emit no media or local echo, and initiate one Noise handshake.
- Authenticated and pinned, or authenticated with a matching verified bit-8 - Live generation waiting for `0x21`: retain that same intent while the
announcement: send encrypted `0x11` / `0x20`. five-second peer-state watchdog runs.
- Authenticated with a matching verified legacy announcement (TLV absent or bit - Fresh proof with bit 8: automatically retry the retained intent and send
clear): ask for explicit one-shot consent before sending this file. encrypted `0x11` / `0x20` after final-packet admission.
- Fresh proof without bit 8, or a five-second no-proof timeout for an unpinned
old client: retry the retained intent by showing the existing explicit
one-shot legacy consent prompt.
- A previously pinned identity that proves no bit, or fails to prove state in
the current generation, is visibly blocked. It cannot silently fall back.
The exact automatic intent is reserved before its first policy evaluation, so a
proof or timeout callback racing that evaluation cannot be lost. It is bounded
and singular, and expires after 15 seconds with a visible system message.
Retries are serialized and always re-run current policy and exact packet
admission. No waiting, rejected, expired, or cancelled attempt creates a local
file echo or transmits raw media; terminal rejection is shown as a system
message rather than failing silently.
The legacy consent path sends a recipient-directed raw The legacy consent path sends a recipient-directed raw
`MessageType.FILE_TRANSFER` (`0x22`) packet. Its contents are visible to relays, `MessageType.FILE_TRANSFER` (`0x22`) packet. Its contents are visible to relays,
@@ -79,9 +120,9 @@ failure aborts the send. Receivers reject unsigned or invalid signed directed
raw files. raw files.
Consent is consumed at most once. On approval the sender re-runs the policy and Consent is consumed at most once. On approval the sender re-runs the policy and
packet admission checks. If the capability became authenticated while the packet admission checks. If a bit-8 proof arrived while the dialog was open,
dialog was open, the send upgrades to encrypted mode. Cancellation, duplicate the send upgrades to encrypted mode. Cancellation, duplicate approval, panic
approval, panic wipe, and changed security state cannot cause a later send. wipe, and changed security state cannot cause a later send.
## Final-packet admission ## Final-packet admission
@@ -102,16 +143,26 @@ capability and bounded streaming design.
## Compatibility summary ## Compatibility summary
- New Android to new iOS/Android: encrypted Noise `0x20` after authenticated - New Android to new iOS/Android: generation-scoped authenticated `0x21`
capability promotion. exchange, then encrypted Noise `0x20`.
- Prerelease iOS to new Android: encrypted Noise `0x09` is decoded, - Prerelease iOS to new Android: encrypted Noise `0x09` is decoded,
canonicalized to `0x20`, and delivered during the migration window. canonicalized to `0x20`, and delivered during the migration window.
- New Android to a verified older client: blocked until the user explicitly - New Android to an older client: the unknown `0x21` is ignored; after the
accepts one relay-visible signed raw `0x22` transfer. five-second watchdog, an unpinned identity may use one explicitly consented,
relay-visible signed raw `0x22` transfer.
- Old clients receiving a new announcement: ignore TLV `0x05` and continue - Old clients receiving a new announcement: ignore TLV `0x05` and continue
operating normally. operating normally. Old clients receiving `0x21` drop the unknown inner type
without affecting their existing Noise session or private messages.
- A previously capable authenticated identity cannot force a silent downgrade - A previously capable authenticated identity cannot force a silent downgrade
by later omitting the bit. by omitting `0x21`, clearing the bit, or changing only its announcement.
Do not remove the `0x21` watchdog/legacy-consent migration path until the
minimum-supported Android and iOS versions both emit an authenticated bit-8
`0x21` proof on every Noise generation, and the released legacy population has
aged out under the project's explicit support policy. Merely observing an
announcement bit or waiting for an arbitrary date is not sufficient. The HSTS
pin remains necessary after that point; removing old-client consent must not
re-enable a raw automatic fallback.
Public media remains signed broadcast `MessageType.FILE_TRANSFER` (`0x22`) and Public media remains signed broadcast `MessageType.FILE_TRANSFER` (`0x22`) and
is outside this private-media capability. is outside this private-media capability.