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