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