diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index 1efa87a2..13e682b4 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -10,8 +10,11 @@ import android.util.Base64 import android.util.Log import com.bitchat.android.model.AuthenticatedPeerState import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.VouchAttestation import com.bitchat.android.util.hexEncodedString import androidx.core.content.edit +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow /** * Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation @@ -37,22 +40,38 @@ class SecureIdentityStateManager { 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" + private const val KEY_VOUCH_RECORDS = "vouch_records_v1" + private const val KEY_VOUCH_BATCH_SENT_AT = "vouch_batch_sent_at_v1" + private const val KEY_VERIFIED_AT = "verified_at_v1" + const val MAX_VOUCHERS_PER_VOUCHEE = 8 + private const val VOUCH_RECORD_FIELD_COUNT = 4 + private const val RECORD_SEPARATOR = ':' + private const val RECORD_SEPARATOR_LENGTH = 1 + private const val VOUCHEE_FIELD_INDEX = 0 + private const val VOUCHER_FIELD_INDEX = 1 + private const val VOUCHEE_SIGNING_KEY_FIELD_INDEX = 2 + private const val INDEX_NOT_FOUND = -1 + private const val IDENTITY_EVENT_BUFFER_CAPACITY = 1 + private const val EXPIRY_TRANSITION_OFFSET_MS = 1L // BLE, Wi-Fi Aware, and Noise services each hold their own manager // instance over the same encrypted preferences. Serialize pin updates // process-wide so concurrent promotions cannot lose one another or // race a panic wipe. - private val privateMediaPinsLock = Any() - private var privateMediaPinsEpoch = 0L + private val identityPersistenceLock = Any() + private var identityPersistenceEpoch = 0L + private val identityChanges = + MutableSharedFlow(extraBufferCapacity = IDENTITY_EVENT_BUFFER_CAPACITY) + val changes = identityChanges.asSharedFlow() } private val prefs: SharedPreferences private val lock = Any() - private var privateMediaPinsEpochAtCreation: Long + private var identityPersistenceEpochAtCreation: Long constructor(context: Context) { - privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) { - privateMediaPinsEpoch + identityPersistenceEpochAtCreation = synchronized(identityPersistenceLock) { + identityPersistenceEpoch } // Create master key for encryption val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) @@ -72,8 +91,8 @@ class SecureIdentityStateManager { /** Test-only storage injection; production always uses encrypted prefs. */ internal constructor(prefs: SharedPreferences, testOnly: Boolean) { require(testOnly) { "Plain SharedPreferences are test-only" } - privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) { - privateMediaPinsEpoch + identityPersistenceEpochAtCreation = synchronized(identityPersistenceLock) { + identityPersistenceEpoch } this.prefs = prefs } @@ -223,19 +242,197 @@ class SecureIdentityStateManager { return getVerifiedFingerprints().contains(fingerprint) } + @SuppressLint("UseKtx") fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) { if (!isValidFingerprint(fingerprint)) return - synchronized(lock) { - val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + val normalizedFingerprint = fingerprint.lowercase() + synchronized(identityPersistenceLock) { + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return + val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet()) + ?.mapTo(mutableSetOf()) { it.lowercase() } ?: mutableSetOf() if (verified) { - current.add(fingerprint) + current.add(normalizedFingerprint) } else { - current.remove(fingerprint) + current.remove(normalizedFingerprint) } - prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) } + val verifiedAt = readTimestampMap(KEY_VERIFIED_AT).toMutableMap() + if (verified) verifiedAt[normalizedFingerprint] = System.currentTimeMillis() + else verifiedAt.remove(normalizedFingerprint) + val committed = prefs.edit() + .putStringSet(KEY_VERIFIED_FINGERPRINTS, current) + .putStringSet(KEY_VERIFIED_AT, encodeTimestampMap(verifiedAt)) + .commit() + if (!committed) return + identityChanges.tryEmit(Unit) } } + data class VouchRecord( + val voucherFingerprint: String, + val voucheeSigningKeyHex: String, + val timestampMs: Long + ) + + @SuppressLint("UseKtx") + fun recordVouch( + voucheeFingerprint: String, + voucherFingerprint: String, + voucheeSigningKey: ByteArray, + timestampMs: Long, + nowMs: Long = System.currentTimeMillis() + ): Boolean { + val vouchee = voucheeFingerprint.lowercase() + val voucher = voucherFingerprint.lowercase() + if (!isValidFingerprint(vouchee) || !isValidFingerprint(voucher) || + voucheeSigningKey.size != VouchAttestation.SIGNING_KEY_SIZE + ) return false + synchronized(identityPersistenceLock) { + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return false + val verified = getVerifiedFingerprints().mapTo(mutableSetOf()) { it.lowercase() } + val age = nowMs - timestampMs + if (vouchee == voucher || voucher !in verified || vouchee in verified || + age > VouchAttestation.MAX_AGE_MS || + age < -VouchAttestation.MAX_CLOCK_SKEW_MS + ) return false + + val all = readVouchRecords().toMutableMap() + val records = all[vouchee].orEmpty().toMutableList() + val existing = records.indexOfFirst { it.voucherFingerprint == voucher } + val proposed = VouchRecord( + voucherFingerprint = voucher, + voucheeSigningKeyHex = voucheeSigningKey.hexEncodedString(), + timestampMs = timestampMs + ) + val record = records.getOrNull(existing) + ?.takeIf { it.timestampMs >= timestampMs } + ?: proposed + if (existing > INDEX_NOT_FOUND) records[existing] = record else records += record + val capped = records.sortedByDescending { it.timestampMs }.take(MAX_VOUCHERS_PER_VOUCHEE) + if (capped.none { it.voucherFingerprint == voucher }) return false + all[vouchee] = capped + val committed = prefs.edit() + .putStringSet(KEY_VOUCH_RECORDS, encodeVouchRecords(all)) + .commit() + if (!committed) return false + identityChanges.tryEmit(Unit) + return true + } + } + + fun validVouchers( + fingerprint: String, + nowMs: Long = System.currentTimeMillis() + ): List { + val normalized = fingerprint.lowercase() + if (!isValidFingerprint(normalized)) return emptyList() + synchronized(identityPersistenceLock) { + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return emptyList() + val verified = getVerifiedFingerprints().mapTo(mutableSetOf()) { it.lowercase() } + val authenticatedSigningKeyHex = getAuthenticatedSigningKey(normalized) + ?.hexEncodedString() ?: return emptyList() + return readVouchRecords()[normalized].orEmpty().filter { + it.voucherFingerprint != normalized && + it.voucherFingerprint in verified && + it.voucheeSigningKeyHex == authenticatedSigningKeyHex && + nowMs - it.timestampMs <= VouchAttestation.MAX_AGE_MS && + nowMs - it.timestampMs >= -VouchAttestation.MAX_CLOCK_SKEW_MS + } + } + } + + fun isVouched(fingerprint: String, nowMs: Long = System.currentTimeMillis()): Boolean { + val normalized = fingerprint.lowercase() + val isExplicitlyVerified = getVerifiedFingerprints().any { + it.equals(normalized, ignoreCase = true) + } + return !isExplicitlyVerified && validVouchers(normalized, nowMs).isNotEmpty() + } + + fun getVouchedFingerprints(nowMs: Long = System.currentTimeMillis()): Set = + synchronized(identityPersistenceLock) { + readVouchRecords().keys.filterTo(mutableSetOf()) { isVouched(it, nowMs) } + } + + fun nextVouchExpiryMs(nowMs: Long = System.currentTimeMillis()): Long? = + synchronized(identityPersistenceLock) { + readVouchRecords().keys + .flatMap { validVouchers(it, nowMs) } + .minOfOrNull { + it.timestampMs + + VouchAttestation.MAX_AGE_MS + + EXPIRY_TRANSITION_OFFSET_MS + } + } + + fun mostRecentlyVerifiedFingerprints(limit: Int, excluding: String): List { + return synchronized(identityPersistenceLock) { + val verifiedAt = readTimestampMap(KEY_VERIFIED_AT) + getVerifiedFingerprints() + .map { it.lowercase() } + .filterNot { it == excluding.lowercase() } + .sortedWith(compareByDescending { verifiedAt[it] ?: Long.MIN_VALUE }.thenByDescending { it }) + .take(limit) + } + } + + fun lastVouchBatchSent(fingerprint: String): Long? = + synchronized(identityPersistenceLock) { + readTimestampMap(KEY_VOUCH_BATCH_SENT_AT)[fingerprint.lowercase()] + } + + @SuppressLint("UseKtx") + fun markVouchBatchSent(fingerprint: String, timestampMs: Long) { + synchronized(identityPersistenceLock) { + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return + val sent = readTimestampMap(KEY_VOUCH_BATCH_SENT_AT).toMutableMap() + sent[fingerprint.lowercase()] = timestampMs + if (!prefs.edit() + .putStringSet(KEY_VOUCH_BATCH_SENT_AT, encodeTimestampMap(sent)) + .commit() + ) { + Log.e(TAG, "Vouch batch timestamp could not be committed") + } + } + } + + private fun readTimestampMap(key: String): Map = + prefs.getStringSet(key, emptySet()).orEmpty().mapNotNull { entry -> + val split = entry.lastIndexOf(RECORD_SEPARATOR) + if (split <= VOUCHEE_FIELD_INDEX) { + null + } else { + entry.substring(split + RECORD_SEPARATOR_LENGTH).toLongOrNull()?.let { + entry.substring(VOUCHEE_FIELD_INDEX, split) to it + } + } + }.toMap() + + private fun encodeTimestampMap(values: Map): Set = + values.mapTo(mutableSetOf()) { (fingerprint, timestamp) -> + "$fingerprint$RECORD_SEPARATOR$timestamp" + } + + private fun readVouchRecords(): Map> = + prefs.getStringSet(KEY_VOUCH_RECORDS, emptySet()).orEmpty().mapNotNull { entry -> + val fields = entry.split(RECORD_SEPARATOR) + if (fields.size != VOUCH_RECORD_FIELD_COUNT) null else fields.last().toLongOrNull()?.let { + fields[VOUCHEE_FIELD_INDEX] to VouchRecord( + voucherFingerprint = fields[VOUCHER_FIELD_INDEX], + voucheeSigningKeyHex = fields[VOUCHEE_SIGNING_KEY_FIELD_INDEX], + timestampMs = it + ) + } + }.groupBy({ it.first }, { it.second }) + + private fun encodeVouchRecords(values: Map>): Set = + values.flatMapTo(mutableSetOf()) { (vouchee, records) -> + records.map { + "$vouchee$RECORD_SEPARATOR${it.voucherFingerprint}" + + "$RECORD_SEPARATOR${it.voucheeSigningKeyHex}" + + "$RECORD_SEPARATOR${it.timestampMs}" + } + } + fun getCachedPeerFingerprint(peerID: String): String? { val pid = peerID.lowercase() // Reading is safe without lock for SharedPreferences, but synchronizing ensures memory visibility @@ -323,8 +520,8 @@ class SecureIdentityStateManager { fun isPrivateMediaCapable(fingerprint: String): Boolean { if (!isValidFingerprint(fingerprint)) return false - return synchronized(privateMediaPinsLock) { - if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) { + return synchronized(identityPersistenceLock) { + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) { return@synchronized false } prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet()) @@ -339,11 +536,13 @@ class SecureIdentityStateManager { state: AuthenticatedPeerState, onCommitted: () -> Unit = {} ): Boolean { - if (!isValidFingerprint(fingerprint) || state.signingPublicKey.size != 32) return false + if (!isValidFingerprint(fingerprint) || + state.signingPublicKey.size != VouchAttestation.SIGNING_KEY_SIZE + ) return false val normalizedFingerprint = fingerprint.lowercase() - return synchronized(privateMediaPinsLock) { + return synchronized(identityPersistenceLock) { // A controller that survived panic must not republish pre-wipe proof state. - if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized false + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return@synchronized false val records = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet()) ?.toMutableSet() ?: mutableSetOf() records.removeAll { it.startsWith("$normalizedFingerprint:") } @@ -362,15 +561,18 @@ class SecureIdentityStateManager { // 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() + if (committed) { + identityChanges.tryEmit(Unit) + onCommitted() + } } } } fun getAuthenticatedPeerState(fingerprint: String): AuthenticatedPeerState? { if (!isValidFingerprint(fingerprint)) return null - return synchronized(privateMediaPinsLock) { - if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized null + return synchronized(identityPersistenceLock) { + if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return@synchronized null val prefix = "${fingerprint.lowercase()}:" val record = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet()) ?.firstOrNull { it.startsWith(prefix) } ?: return@synchronized null @@ -468,12 +670,13 @@ class SecureIdentityStateManager { @SuppressLint("UseKtx") fun clearIdentityData() { try { - synchronized(privateMediaPinsLock) { - privateMediaPinsEpoch += 1 - privateMediaPinsEpochAtCreation = privateMediaPinsEpoch + synchronized(identityPersistenceLock) { + identityPersistenceEpoch += 1 + identityPersistenceEpochAtCreation = identityPersistenceEpoch if (!prefs.edit().clear().commit()) { Log.e(TAG, "Identity preference wipe could not be committed") } + identityChanges.tryEmit(Unit) } Log.w(TAG, "All identity data cleared") } catch (e: Exception) { diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 13d42f5d..62e65309 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -20,6 +20,7 @@ import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString import com.bitchat.android.services.VerificationService import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.identity.SecureIdentityStateManager import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import java.util.* @@ -60,6 +61,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars) val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) private val peerManager = PeerManager() + private val identityState = SecureIdentityStateManager(context.applicationContext) private val fragmentManager = FragmentManager() private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val readReceiptRetrySender = RetryingControlPacketSender(serviceScope) @@ -138,6 +140,20 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // Coroutines // Tracks whether this instance has been terminated via stopServices() private var terminated = false + private val vouchCoordinator by lazy { + VouchCoordinator( + scope = serviceScope, + identity = identityState, + connectedPeerIDs = peerManager::getActivePeerIDs, + fingerprintForPeer = peerManager::getFingerprintForPeer, + peerInfo = peerManager::getPeerInfo, + signingKeyForFingerprint = ::signingKeyForFingerprint, + hasEstablishedSession = encryptionService::hasEstablishedSession, + sign = encryptionService::signData, + verify = encryptionService::verifyEd25519Signature, + send = ::sendVouchPayload + ) + } init { serviceScope.launch { @@ -275,6 +291,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun onPeerListUpdated(peerIDs: List) { // Update process-wide state first try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peerIDs) } catch (_: Exception) { } + vouchCoordinator.peersUpdated(peerIDs) // Then notify UI delegate if attached delegate?.didUpdatePeerList(peerIDs) } @@ -307,6 +324,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic authenticatedRemoteStaticKey, authenticatedSessionToken ) + vouchCoordinator.peerAuthenticated( + peerID, + identityState.generateFingerprint(authenticatedRemoteStaticKey) + ) // Send announcement and cached messages after key exchange serviceScope.launch { delay(100) @@ -580,6 +601,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) { delegate?.didReceiveGroupMessage(payload, timestampMs) } + + override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) { + vouchCoordinator.handlePayload(peerID, payload) + } } // PacketProcessor delegates @@ -988,6 +1013,31 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic return true } + private fun signingKeyForFingerprint(fingerprint: String): ByteArray? { + identityState.getAuthenticatedSigningKey(fingerprint)?.let { return it } + return peerManager.getActivePeerIDs().firstNotNullOfOrNull { peerID -> + val peerFingerprint = peerManager.getFingerprintForPeer(peerID) + peerManager.getPeerInfo(peerID)?.signingPublicKey + ?.takeIf { peerFingerprint.equals(fingerprint, ignoreCase = true) } + } + } + + private fun sendVouchPayload(peerID: String, payload: ByteArray): Boolean { + val plaintext = NoisePayload(NoisePayloadType.VOUCH, payload).encode() + val encrypted = securityManager.encryptForPeer(plaintext, peerID) ?: return false + val packet = BitchatPacket( + version = VouchCoordinator.NOISE_PACKET_VERSION, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + ttl = MAX_TTL + ) + broadcastRoutedPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) + return true + } + fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) { try { val payload = file.encode() diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index c625da61..15b290af 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -17,6 +17,7 @@ import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString @@ -56,6 +57,7 @@ class MeshCore( ) private val peerManager = PeerManager() + private val identityState = SecureIdentityStateManager(context.applicationContext) val fragmentManager = FragmentManager() private val readReceiptRetrySender = RetryingControlPacketSender(scope) private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context) @@ -117,6 +119,20 @@ class MeshCore( private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray) private val voiceFrameQueue = Channel(capacity = 128) private val directPeers = ConcurrentHashMap.newKeySet() + private val vouchCoordinator by lazy { + VouchCoordinator( + scope = scope, + identity = identityState, + connectedPeerIDs = peerManager::getActivePeerIDs, + fingerprintForPeer = peerManager::getFingerprintForPeer, + peerInfo = peerManager::getPeerInfo, + signingKeyForFingerprint = ::signingKeyForFingerprint, + hasEstablishedSession = encryptionService::hasEstablishedSession, + sign = encryptionService::signData, + verify = encryptionService::verifyEd25519Signature, + send = ::sendVouchPayload + ) + } val gossipSyncManager: GossipSyncManager = sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider) @@ -220,6 +236,7 @@ class MeshCore( peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { } + vouchCoordinator.peersUpdated(peerIDs) delegate?.didUpdatePeerList(peerIDs) } @@ -245,6 +262,10 @@ class MeshCore( authenticatedRemoteStaticKey, authenticatedSessionToken ) + vouchCoordinator.peerAuthenticated( + peerID, + identityState.generateFingerprint(authenticatedRemoteStaticKey) + ) scope.launch { delay(100) sendAnnouncementToPeer(peerID) @@ -460,6 +481,10 @@ class MeshCore( override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) { delegate?.didReceiveGroupMessage(payload, timestampMs) } + + override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) { + vouchCoordinator.handlePayload(peerID, payload) + } } packetProcessor.delegate = object : PacketProcessorDelegate { @@ -637,6 +662,31 @@ class MeshCore( return true } + private fun signingKeyForFingerprint(fingerprint: String): ByteArray? { + identityState.getAuthenticatedSigningKey(fingerprint)?.let { return it } + return peerManager.getActivePeerIDs().firstNotNullOfOrNull { peerID -> + val peerFingerprint = peerManager.getFingerprintForPeer(peerID) + peerManager.getPeerInfo(peerID)?.signingPublicKey + ?.takeIf { peerFingerprint.equals(fingerprint, ignoreCase = true) } + } + } + + private fun sendVouchPayload(peerID: String, payload: ByteArray): Boolean { + val plaintext = NoisePayload(NoisePayloadType.VOUCH, payload).encode() + val encrypted = securityManager.encryptForPeer(plaintext, peerID) ?: return false + val packet = BitchatPacket( + version = VouchCoordinator.NOISE_PACKET_VERSION, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + ttl = maxTtl + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + return true + } + fun sendFileBroadcast(file: BitchatFilePacket) { try { val payload = file.encode() ?: return diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index e935b21b..a733ae86 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -213,6 +213,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro noisePayload.data ) } + com.bitchat.android.model.NoisePayloadType.VOUCH -> { + delegate?.onVouchPayloadReceived(peerID, noisePayload.data) + } } } catch (e: Exception) { @@ -802,4 +805,5 @@ interface MessageHandlerDelegate { payload: ByteArray ) {} fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {} + fun onVouchPayloadReceived(peerID: String, payload: ByteArray) {} } diff --git a/app/src/main/java/com/bitchat/android/mesh/VouchCoordinator.kt b/app/src/main/java/com/bitchat/android/mesh/VouchCoordinator.kt new file mode 100644 index 00000000..8e5d6fb0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/VouchCoordinator.kt @@ -0,0 +1,127 @@ +package com.bitchat.android.mesh + +import android.util.Log +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.VouchAttestation +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + +/** + * Transport-neutral exchange and acceptance policy for transitive verification. + * + * All payloads supplied to [handlePayload] have already been authenticated and + * decrypted by the Noise session for [peerID]. + */ +class VouchCoordinator( + private val scope: CoroutineScope, + private val identity: SecureIdentityStateManager, + private val connectedPeerIDs: () -> Collection, + private val fingerprintForPeer: (String) -> String?, + private val peerInfo: (String) -> PeerInfo?, + private val signingKeyForFingerprint: (String) -> ByteArray?, + private val hasEstablishedSession: (String) -> Boolean, + private val sign: (ByteArray) -> ByteArray?, + private val verify: (ByteArray, ByteArray, ByteArray) -> Boolean, + private val send: (String, ByteArray) -> Boolean +) { + init { + scope.launch { + SecureIdentityStateManager.changes.collect { + vouchToConnectedVerifiedPeers() + } + } + } + + fun peerAuthenticated(peerID: String, fingerprint: String) { + attemptVouch(peerID, fingerprint) + } + + fun peersUpdated(peerIDs: Collection) { + peerIDs.forEach { peerID -> + fingerprintForPeer(peerID)?.let { attemptVouch(peerID, it) } + } + } + + fun vouchToConnectedVerifiedPeers(nowMs: Long = System.currentTimeMillis()) { + connectedPeerIDs().forEach { peerID -> + fingerprintForPeer(peerID)?.let { attemptVouch(peerID, it, nowMs) } + } + } + + fun attemptVouch( + peerID: String, + peerFingerprint: String, + nowMs: Long = System.currentTimeMillis() + ): Boolean { + val normalizedPeerFingerprint = peerFingerprint.lowercase() + if (!hasEstablishedSession(peerID) || + !identity.isVerifiedFingerprint(normalizedPeerFingerprint) + ) return false + + val capabilities = peerInfo(peerID)?.capabilities + if (capabilities != null && capabilities != PeerCapabilities.NONE && + !capabilities.contains(PeerCapabilities.VOUCH) + ) return false + + val lastSent = identity.lastVouchBatchSent(normalizedPeerFingerprint) + if (lastSent != null && nowMs - lastSent < BATCH_INTERVAL_MS) return false + + val attestations = identity.mostRecentlyVerifiedFingerprints( + VouchAttestation.MAX_BATCH_COUNT, + excluding = normalizedPeerFingerprint + ).mapNotNull { vouchee -> + val fingerprintBytes = vouchee.dataFromHexString() ?: return@mapNotNull null + val signingKey = signingKeyForFingerprint(vouchee) ?: return@mapNotNull null + VouchAttestation.build(fingerprintBytes, signingKey, nowMs, sign) + } + val payload = VouchAttestation.encodeList(attestations) ?: return false + if (!send(peerID, payload)) return false + identity.markVouchBatchSent(normalizedPeerFingerprint, nowMs) + Log.d(TAG, "Sent ${attestations.size} vouch(es) to ${peerID.take(LOG_FINGERPRINT_LENGTH)}") + return true + } + + fun handlePayload( + peerID: String, + payload: ByteArray, + nowMs: Long = System.currentTimeMillis() + ) { + val senderFingerprint = fingerprintForPeer(peerID)?.lowercase() ?: return + if (!identity.isVerifiedFingerprint(senderFingerprint)) return + val senderSigningKey = signingKeyForFingerprint(senderFingerprint) ?: return + + var accepted = INITIAL_ACCEPTED_COUNT + VouchAttestation.decodeList(payload).forEach { attestation -> + if (!attestation.isExpired(nowMs) && + verify(attestation.signature, attestation.signableBytes(), senderSigningKey) && + identity.recordVouch( + attestation.voucheeFingerprint.hexEncodedString(), + senderFingerprint, + attestation.voucheeSigningKey, + attestation.timestampMs, + nowMs + ) + ) accepted++ + } + if (accepted > INITIAL_ACCEPTED_COUNT) { + Log.i(TAG, "Accepted $accepted vouch(es) from ${peerID.take(LOG_FINGERPRINT_LENGTH)}") + } + } + + companion object { + private const val TAG = "VouchCoordinator" + private const val INITIAL_ACCEPTED_COUNT = 0 + private const val LOG_FINGERPRINT_LENGTH = 8 + private const val HOURS_PER_DAY = 24L + private const val MINUTES_PER_HOUR = 60L + private const val SECONDS_PER_MINUTE = 60L + private const val MILLIS_PER_SECOND = 1000L + const val BATCH_INTERVAL_MS = + HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND + val NOISE_PACKET_VERSION: UByte = 1u + } +} diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt index ca38f3fa..7aa0e843 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -26,6 +26,7 @@ enum class NoisePayloadType(val value: UByte) { GROUP_KEY_UPDATE(0x07u), // Creator-signed roster/key rotation VERIFY_CHALLENGE(0x10u), // Verification challenge VERIFY_RESPONSE(0x11u), // Verification response + VOUCH(0x12u), // Transitive verification attestations FILE_TRANSFER(0x20u), /** Authenticated capabilities + Ed25519 binding for the current Noise generation. */ PEER_STATE(0x21u); diff --git a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt index 7b9b7672..0327a00a 100644 --- a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -27,6 +27,8 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable { } companion object { + private const val VOUCH_BIT_INDEX = 5 + private const val PRIVATE_MEDIA_BIT_INDEX = 8 val NONE = PeerCapabilities(0) val PREKEYS = PeerCapabilities(1L shl 0) @@ -34,12 +36,14 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable { val GATEWAY = PeerCapabilities(1L shl 2) val GROUPS = PeerCapabilities(1L shl 3) val BOARD = PeerCapabilities(1L shl 4) - val VOUCH = PeerCapabilities(1L shl 5) val MESH_DIAGNOSTICS = PeerCapabilities(1L shl 6) val BRIDGE = PeerCapabilities(1L shl 7) /** Noise-encrypted private BitchatFilePacket using payload type 0x20. */ - val PRIVATE_MEDIA = PeerCapabilities(1L shl 8) + val PRIVATE_MEDIA = PeerCapabilities(1L shl PRIVATE_MEDIA_BIT_INDEX) + + /** Transitive verification attestations over authenticated Noise. */ + val VOUCH = PeerCapabilities(1L shl VOUCH_BIT_INDEX) val PRIVATE_MEDIA_RECEIPTS = PeerCapabilities(1L shl 9) @@ -47,7 +51,7 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable { val NON_DESTRUCTIVE_NOISE_REPLACEMENT = PeerCapabilities(1L shl 10) /** Capabilities implemented by this Android build. */ - val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or GROUPS.rawValue or BOARD.rawValue) + val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or GROUPS.rawValue or BOARD.rawValue or VOUCH.rawValue) /** * Decode the low 64 bits and ignore any future extension bytes, which diff --git a/app/src/main/java/com/bitchat/android/model/VouchAttestation.kt b/app/src/main/java/com/bitchat/android/model/VouchAttestation.kt new file mode 100644 index 00000000..9987cc15 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/VouchAttestation.kt @@ -0,0 +1,193 @@ +package com.bitchat.android.model + +import java.io.ByteArrayOutputStream + +/** + * An iOS-compatible, Ed25519-signed statement that the sender of the enclosing + * authenticated Noise payload verified [voucheeFingerprint]. + */ +data class VouchAttestation( + val voucheeFingerprint: ByteArray, + val voucheeSigningKey: ByteArray, + val timestampMs: Long, + val signature: ByteArray +) { + init { + require(voucheeFingerprint.size == FINGERPRINT_SIZE) + require(voucheeSigningKey.size == SIGNING_KEY_SIZE) + require(signature.size == SIGNATURE_SIZE) + } + + fun signableBytes(): ByteArray = signableBytes( + voucheeFingerprint, + voucheeSigningKey, + timestampMs + ) + + fun isExpired(nowMs: Long = System.currentTimeMillis()): Boolean { + val age = nowMs - timestampMs + return age > MAX_AGE_MS || age < -MAX_CLOCK_SKEW_MS + } + + fun encode(): ByteArray { + val output = ByteArrayOutputStream() + output.writeTlv(TYPE_FINGERPRINT, voucheeFingerprint) + output.writeTlv(TYPE_SIGNING_KEY, voucheeSigningKey) + output.writeTlv(TYPE_TIMESTAMP, timestampBytes(timestampMs)) + output.writeTlv(TYPE_SIGNATURE, signature) + return output.toByteArray() + } + + override fun equals(other: Any?): Boolean = + other is VouchAttestation && + voucheeFingerprint.contentEquals(other.voucheeFingerprint) && + voucheeSigningKey.contentEquals(other.voucheeSigningKey) && + timestampMs == other.timestampMs && + signature.contentEquals(other.signature) + + override fun hashCode(): Int { + var result = voucheeFingerprint.contentHashCode() + result = HASH_MULTIPLIER * result + voucheeSigningKey.contentHashCode() + result = HASH_MULTIPLIER * result + timestampMs.hashCode() + return HASH_MULTIPLIER * result + signature.contentHashCode() + } + + companion object { + const val MAX_BATCH_COUNT = 16 + const val FINGERPRINT_SIZE = 32 + const val SIGNING_KEY_SIZE = 32 + const val SIGNATURE_SIZE = 64 + private const val DAYS_VALID = 30L + private const val HOURS_PER_DAY = 24L + private const val MINUTES_PER_HOUR = 60L + private const val SECONDS_PER_MINUTE = 60L + private const val MILLIS_PER_SECOND = 1000L + const val MAX_AGE_MS = + DAYS_VALID * HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND + const val MAX_CLOCK_SKEW_MS = + MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND + + private const val SIGNING_CONTEXT = "bitchat-vouch-v1" + private const val TYPE_FINGERPRINT = 0x01 + private const val TYPE_SIGNING_KEY = 0x02 + private const val TYPE_TIMESTAMP = 0x03 + private const val TYPE_SIGNATURE = 0x04 + private const val TLV_HEADER_SIZE = 2 + private const val TLV_LENGTH_SIZE = 1 + private const val BATCH_COUNT_SIZE = 1 + private const val BATCH_ENTRY_LENGTH_SIZE = 2 + private const val BITS_PER_BYTE = 8 + private const val BYTE_MASK = 0xFF + private const val UINT16_MAX = 0xFFFF + private const val INITIAL_OFFSET = 0 + private const val INITIAL_TIMESTAMP = 0L + private const val MINIMUM_TIMESTAMP_MS = 0L + private const val INITIAL_ENTRY_COUNT = 0 + private const val HASH_MULTIPLIER = 31 + private const val TIMESTAMP_LENGTH = Long.SIZE_BYTES + + fun build( + voucheeFingerprint: ByteArray, + voucheeSigningKey: ByteArray, + timestampMs: Long = System.currentTimeMillis(), + sign: (ByteArray) -> ByteArray? + ): VouchAttestation? { + if (voucheeFingerprint.size != FINGERPRINT_SIZE || + voucheeSigningKey.size != SIGNING_KEY_SIZE + ) return null + val signature = sign(signableBytes(voucheeFingerprint, voucheeSigningKey, timestampMs)) + ?: return null + if (signature.size != SIGNATURE_SIZE) return null + return VouchAttestation(voucheeFingerprint, voucheeSigningKey, timestampMs, signature) + } + + fun signableBytes( + voucheeFingerprint: ByteArray, + voucheeSigningKey: ByteArray, + timestampMs: Long + ): ByteArray = SIGNING_CONTEXT.toByteArray(Charsets.UTF_8) + + voucheeFingerprint + voucheeSigningKey + timestampBytes(timestampMs) + + fun decode(data: ByteArray): VouchAttestation? { + var offset = INITIAL_OFFSET + var fingerprint: ByteArray? = null + var signingKey: ByteArray? = null + var timestamp: Long? = null + var signature: ByteArray? = null + while (offset < data.size) { + if (offset + TLV_HEADER_SIZE > data.size) return null + val type = data[offset++].toInt() and BYTE_MASK + val length = data[offset++].toInt() and BYTE_MASK + if (offset + length > data.size) return null + val value = data.copyOfRange(offset, offset + length) + offset += length + when (type) { + TYPE_FINGERPRINT -> if (length == FINGERPRINT_SIZE) fingerprint = value else return null + TYPE_SIGNING_KEY -> if (length == SIGNING_KEY_SIZE) signingKey = value else return null + TYPE_TIMESTAMP -> if (length == TIMESTAMP_LENGTH) { + val decodedTimestamp = value.fold(INITIAL_TIMESTAMP) { result, byte -> + (result shl BITS_PER_BYTE) or (byte.toLong() and BYTE_MASK.toLong()) + } + if (decodedTimestamp < MINIMUM_TIMESTAMP_MS) return null + timestamp = decodedTimestamp + } else return null + TYPE_SIGNATURE -> if (length == SIGNATURE_SIZE) signature = value else return null + } + } + return VouchAttestation( + fingerprint ?: return null, + signingKey ?: return null, + timestamp ?: return null, + signature ?: return null + ) + } + + fun encodeList(attestations: List): ByteArray? { + if (attestations.isEmpty() || attestations.size > MAX_BATCH_COUNT) return null + val output = ByteArrayOutputStream() + output.write(attestations.size) + attestations.forEach { attestation -> + val encoded = attestation.encode() + if (encoded.size > UINT16_MAX) return null + output.write(encoded.size ushr BITS_PER_BYTE) + output.write(encoded.size and BYTE_MASK) + output.write(encoded) + } + return output.toByteArray() + } + + fun decodeList(data: ByteArray): List { + if (data.size <= BATCH_COUNT_SIZE) return emptyList() + val limit = minOf(data[0].toInt() and BYTE_MASK, MAX_BATCH_COUNT) + val decoded = mutableListOf() + var offset = BATCH_COUNT_SIZE + var entriesRead = INITIAL_ENTRY_COUNT + while (entriesRead < limit && offset < data.size) { + if (offset + BATCH_ENTRY_LENGTH_SIZE > data.size) break + val length = ((data[offset].toInt() and BYTE_MASK) shl BITS_PER_BYTE) or + (data[offset + TLV_LENGTH_SIZE].toInt() and BYTE_MASK) + offset += BATCH_ENTRY_LENGTH_SIZE + if (offset + length > data.size) break + decode(data.copyOfRange(offset, offset + length))?.let(decoded::add) + offset += length + entriesRead++ + } + return decoded + } + + private const val LAST_BYTE_INDEX_OFFSET = 1 + private const val TIMESTAMP_HIGH_BIT_OFFSET = + (TIMESTAMP_LENGTH - LAST_BYTE_INDEX_OFFSET) * BITS_PER_BYTE + + private fun timestampBytes(timestampMs: Long): ByteArray = + ByteArray(TIMESTAMP_LENGTH) { index -> + (timestampMs ushr (TIMESTAMP_HIGH_BIT_OFFSET - index * BITS_PER_BYTE)).toByte() + } + + private fun ByteArrayOutputStream.writeTlv(type: Int, value: ByteArray) { + write(type) + write(value.size) + write(value) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index 0257c756..c5385759 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -241,10 +241,8 @@ class NostrClient private constructor(private val context: Context) { giftWrap: NostrEvent, handler: (content: String, senderNpub: String, timestamp: Int) -> Unit ) { - // Age filtering (24h + 15min buffer for randomized timestamps) - val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt - if (messageAge > 173700) { // 48 hours + 15 minutes - Log.v(TAG, "Ignoring old private message") + if (!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt)) { + Log.v(TAG, "Ignoring private message with implausible gift-wrap created_at") return } @@ -254,6 +252,10 @@ class NostrClient private constructor(private val context: Context) { val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity) if (decryptResult != null) { val (content, senderPubkey, timestamp) = decryptResult + if (!NostrTimestampPolicy.isPlausibleRumorTimestamp(timestamp)) { + Log.w(TAG, "Dropping private message with implausible rumor timestamp") + return + } // Convert sender pubkey to npub val senderNpub = try { diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 8a4427ab..b483ad75 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -60,8 +60,10 @@ class NostrDirectMessageHandler( try { if (dedupe(giftWrap.id)) return@launch - val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt - if (messageAge > 173700) return@launch // 48 hours + 15 mins + if (!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt)) { + Log.v(TAG, "Ignoring gift wrap with implausible created_at") + return@launch + } val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity) if (decryptResult == null) { @@ -70,6 +72,10 @@ class NostrDirectMessageHandler( } val (content, rawSenderPubkey, rumorTimestamp) = decryptResult + if (!NostrTimestampPolicy.isPlausibleRumorTimestamp(rumorTimestamp)) { + Log.w(TAG, "Dropping Nostr DM with implausible rumor timestamp") + return@launch + } val senderPubkey = rawSenderPubkey.lowercase() // If sender is blocked for geohash contexts, drop any events from this pubkey @@ -247,6 +253,7 @@ class NostrDirectMessageHandler( NoisePayloadType.VOICE_FRAME, NoisePayloadType.GROUP_INVITE, NoisePayloadType.GROUP_KEY_UPDATE, + NoisePayloadType.VOUCH, NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation. } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTimestampPolicy.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTimestampPolicy.kt new file mode 100644 index 00000000..1daca641 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTimestampPolicy.kt @@ -0,0 +1,42 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.util.AppConstants + +/** + * Client-side timestamp windows for inbound Nostr DMs. + * + * Mirrors iOS `NostrInboundPipeline.isPlausibleRumorTimestamp`: a relay that + * ignores the subscription `since` filter — or replays archived events — must + * not inject stale or future-dated DMs. The inner rumor timestamp is the + * sender's true send time; only the outer gift wrap is NIP-17-randomized. + */ +object NostrTimestampPolicy { + + /** + * Accept an inner rumor `created_at` inside + * `[now − lookback − skew, now + skew]`. + */ + fun isPlausibleRumorTimestamp( + tsSeconds: Int, + nowSeconds: Long = System.currentTimeMillis() / 1000L + ): Boolean { + val age = nowSeconds - tsSeconds.toLong() + val skew = AppConstants.Nostr.DM_MAX_CLOCK_SKEW_SECONDS + val lookback = AppConstants.Nostr.DM_SUBSCRIBE_LOOKBACK_SECONDS + return age >= -skew && age <= lookback + skew + } + + /** + * Accept an outer gift-wrap `created_at` that is not in the far future and + * not older than the NIP-17 randomization ceiling plus skew. + */ + fun isAcceptableGiftWrapTimestamp( + createdAtSeconds: Int, + nowSeconds: Long = System.currentTimeMillis() / 1000L + ): Boolean { + val age = nowSeconds - createdAtSeconds.toLong() + val skew = AppConstants.Nostr.DM_MAX_CLOCK_SKEW_SECONDS + val maxAge = AppConstants.Nostr.DM_GIFT_WRAP_MAX_AGE_SECONDS + return age >= -skew && age <= maxAge + } +} diff --git a/app/src/main/java/com/bitchat/android/services/VerificationService.kt b/app/src/main/java/com/bitchat/android/services/VerificationService.kt index 06dcd6f3..21719bbe 100644 --- a/app/src/main/java/com/bitchat/android/services/VerificationService.kt +++ b/app/src/main/java/com/bitchat/android/services/VerificationService.kt @@ -146,7 +146,10 @@ object VerificationService { val service = encryptionServiceRef?.get() ?: return null val qr = VerificationQR.fromUrlString(urlString) ?: return null val now = System.currentTimeMillis() / 1000L - if (now - qr.ts > maxAgeSeconds) return null + // Freshness in both directions: a future-dated timestamp must not + // buy a QR a longer validity window than a fresh one gets. iOS uses + // the same abs() check in VerificationService.verifyScannedQR. + if (verificationTimestampSkewSeconds(now, qr.ts) > maxAgeSeconds) return null val sig = qr.sigHex.dataFromHexString() ?: return null val signKey = qr.signKeyHex.dataFromHexString() ?: return null @@ -292,3 +295,10 @@ object VerificationService { var last: CacheEntry? = null } } + +/** Absolute age of a verification QR timestamp, in seconds. */ +internal fun verificationTimestampSkewSeconds(nowSeconds: Long, qrTimestampSeconds: Long): Long { + if (nowSeconds < 0 || qrTimestampSeconds < 0) return Long.MAX_VALUE + return if (nowSeconds >= qrTimestampSeconds) nowSeconds - qrTimestampSeconds + else qrTimestampSeconds - nowSeconds +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index de1252bb..e2989b54 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -398,6 +398,7 @@ class ChatViewModel( messageManager = messageManager ) val verifiedFingerprints = verificationHandler.verifiedFingerprints + val vouchedFingerprints = verificationHandler.vouchedFingerprints // Media file sending manager private val mediaSendingManager = MediaSendingManager( @@ -1488,6 +1489,20 @@ class ChatViewModel( return verifiedFingerprints.contains(fingerprint) } + fun isFingerprintVouched(fingerprint: String): Boolean = + verificationHandler.isFingerprintVouched(fingerprint) + + fun isNoisePublicKeyVouched(noisePublicKey: ByteArray): Boolean = + verificationHandler.isFingerprintVouched( + verificationHandler.fingerprintFromNoiseBytes(noisePublicKey) + ) + + fun vouchersForFingerprint(fingerprint: String) = + verificationHandler.vouchersForFingerprint(fingerprint) + + fun voucherNamesForFingerprint(fingerprint: String): List = + verificationHandler.voucherNamesForFingerprint(fingerprint) + fun unverifyFingerprint(peerID: String) { verificationHandler.unverifyFingerprint(peerID) } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 233e9a47..ec0ce080 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -77,6 +77,8 @@ import com.bitchat.android.util.hexEncodedString import kotlinx.coroutines.launch import kotlinx.coroutines.delay +private val TRUST_BADGE_SPACING = 4.dp +private val TRUST_BADGE_SIZE = 14.dp /** * Sheet components for ChatScreen @@ -801,6 +803,7 @@ fun PeopleSection( val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle() val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val vouchedFingerprints by viewModel.vouchedFingerprints.collectAsStateWithLifecycle() // Reactive favorite computation for all peers val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { @@ -925,6 +928,8 @@ fun PeopleSection( val isFavorite = peerFavoriteStates[peerID] ?: false val theyFavoritedUs = peerTheyFavoritedUsStates[peerID] ?: false val isVerified = peerVerifiedStates[peerID] ?: false + val isVouched = !isVerified && + peerFingerprints[peerID]?.lowercase() in vouchedFingerprints // fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below val noiseHex = noiseHexByPeerID[peerID] @@ -952,6 +957,7 @@ fun PeopleSection( isFavorite = isFavorite, theyFavoritedUs = theyFavoritedUs, isVerified = isVerified, + isVouched = isVouched, colorScheme = colorScheme, viewModel = viewModel, onItemClick = { onPrivateChatStart(peerID) }, @@ -982,6 +988,7 @@ fun PeopleSection( val showHash = (baseNameCounts[bName] ?: 0) > 1 val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints) + val isVouched = !isVerified && viewModel.isNoisePublicKeyVouched(fav.peerNoisePublicKey) val unreadCount = ( privateChats[conversationID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(conversationID) } ?: 0 @@ -998,6 +1005,7 @@ fun PeopleSection( isFavorite = true, theyFavoritedUs = fav.theyFavoritedUs, isVerified = isVerified, + isVouched = isVouched, colorScheme = colorScheme, viewModel = viewModel, onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) }, @@ -1584,6 +1592,7 @@ private fun PeerItem( isFavorite: Boolean, theyFavoritedUs: Boolean = false, isVerified: Boolean, + isVouched: Boolean, colorScheme: ColorScheme, viewModel: ChatViewModel, onItemClick: () -> Unit, @@ -1681,6 +1690,23 @@ private fun PeerItem( color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } + + if (isVerified) { + Icon( + painter = painterResource(R.drawable.ic_spec_check), + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(16.dp), + tint = colorScheme.primary + ) + } else if (isVouched) { + Spacer(modifier = Modifier.width(TRUST_BADGE_SPACING)) + Icon( + imageVector = Icons.Outlined.VerifiedUser, + contentDescription = stringResource(R.string.fingerprint_status_vouched), + modifier = Modifier.size(TRUST_BADGE_SIZE), + tint = baseColor + ) + } } UnreadBadge( diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 71e59f0d..aa4a1e6f 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.filled.Verified import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.NoEncryption import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.VerifiedUser import androidx.compose.material.icons.outlined.Warning as OutlinedWarning import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -37,6 +38,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -50,6 +52,8 @@ import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.services.ContactDirectory +private const val VOUCHED_SECONDARY_CONTENT_ALPHA = 0.8f + private data class SecurityStatusInfo( val text: String, val icon: ImageVector, @@ -68,6 +72,7 @@ fun SecurityVerificationSheet( val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val vouchedFingerprints by viewModel.vouchedFingerprints.collectAsStateWithLifecycle() val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() val colorScheme = MaterialTheme.colorScheme @@ -107,8 +112,12 @@ fun SecurityVerificationSheet( activeMeshPeerID = activeMeshPeerID, peerSessionStates = peerSessionStates ) + val isVouched = !isVerified && + fingerprint?.lowercase() in vouchedFingerprints + val voucherNames = fingerprint?.let(viewModel::voucherNamesForFingerprint).orEmpty() val statusInfo = buildStatusInfo( isVerified = isVerified, + isVouched = isVouched, sessionState = sessionState, accent = accent ) @@ -136,6 +145,8 @@ fun SecurityVerificationSheet( SecurityVerificationActions( isVerified = isVerified, + isVouched = isVouched, + voucherNames = voucherNames, fingerprint = fingerprint, displayName = displayName, accent = accent, @@ -175,11 +186,13 @@ private fun SecurityVerificationHeader( @Composable private fun buildStatusInfo( isVerified: Boolean, + isVouched: Boolean, sessionState: String?, accent: Color ): SecurityStatusInfo { val text = when { isVerified -> stringResource(R.string.fingerprint_status_verified) + isVouched -> stringResource(R.string.fingerprint_status_vouched) sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted) sessionState == "handshaking" -> stringResource(R.string.fingerprint_status_handshaking) sessionState == "failed" -> stringResource(R.string.fingerprint_status_failed) @@ -187,6 +200,7 @@ private fun buildStatusInfo( } val icon = when { isVerified -> Icons.Filled.Verified + isVouched -> Icons.Outlined.VerifiedUser sessionState == "handshaking" -> Icons.Outlined.Sync sessionState == "failed" -> Icons.Outlined.OutlinedWarning sessionState == "established" -> Icons.Filled.Lock @@ -194,6 +208,7 @@ private fun buildStatusInfo( } val tint = when { isVerified -> Color(0xFF32D74B) + isVouched -> accent sessionState == "failed" -> Color(0xFFFF3B30) sessionState == "handshaking" -> Color(0xFFFF9500) sessionState == "established" -> Color(0xFF32D74B) @@ -245,6 +260,8 @@ private fun SecurityStatusCard( @Composable private fun SecurityVerificationActions( isVerified: Boolean, + isVouched: Boolean, + voucherNames: List, fingerprint: String?, displayName: String, accent: Color, @@ -301,6 +318,34 @@ private fun SecurityVerificationActions( ) } } else { + if (isVouched) { + VerificationStatusRow( + icon = Icons.Outlined.VerifiedUser, + iconTint = accent, + text = stringResource(R.string.fingerprint_vouched_label), + textTint = accent + ) + Text( + text = pluralStringResource( + R.plurals.fingerprint_vouched_message, + voucherNames.size, + voucherNames.size + ), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = BitchatFontFamily), + color = accent.copy(alpha = VOUCHED_SECONDARY_CONTENT_ALPHA), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + if (voucherNames.isNotEmpty()) { + Text( + text = voucherNames.joinToString(), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = BitchatFontFamily), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } VerificationStatusRow( icon = Icons.Filled.Warning, iconTint = Color(0xFFFF9500), diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt index 8003508e..1250be47 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt @@ -13,9 +13,12 @@ import com.bitchat.android.services.VerificationService import com.bitchat.android.util.dataFromHexString import com.bitchat.android.util.hexEncodedString import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import java.security.MessageDigest import java.util.Date @@ -33,20 +36,48 @@ class VerificationHandler( private val notificationManager: NotificationManager, private val messageManager: MessageManager ) { + companion object { + private const val FINGERPRINT_NAME_PREFIX_LENGTH = 8 + private const val MINIMUM_EXPIRY_REFRESH_DELAY_MS = 1L + } + // Helper to get current mesh service (may change after panic clear) private val meshService: MeshService get() = getMeshService() private val _verifiedFingerprints = MutableStateFlow>(emptySet()) val verifiedFingerprints: StateFlow> = _verifiedFingerprints.asStateFlow() + private val _vouchedFingerprints = MutableStateFlow>(emptySet()) + val vouchedFingerprints: StateFlow> = _vouchedFingerprints.asStateFlow() private val pendingQRVerifications = ConcurrentHashMap() private val lastVerifyNonceByPeer = ConcurrentHashMap() private val lastInboundVerifyChallengeAt = ConcurrentHashMap() private val lastMutualToastAt = ConcurrentHashMap() + private var vouchExpiryRefreshJob: Job? = null + + init { + scope.launch { + SecureIdentityStateManager.changes.collect { + refreshIdentityState() + } + } + } fun loadVerifiedFingerprints() { + refreshIdentityState() + } + + private fun refreshIdentityState(nowMs: Long = System.currentTimeMillis()) { _verifiedFingerprints.value = identityManager.getVerifiedFingerprints() + _vouchedFingerprints.value = identityManager.getVouchedFingerprints(nowMs) + vouchExpiryRefreshJob?.cancel() + val nextExpiryMs = identityManager.nextVouchExpiryMs(nowMs) ?: return + val refreshDelayMs = (nextExpiryMs - nowMs).coerceAtLeast(MINIMUM_EXPIRY_REFRESH_DELAY_MS) + vouchExpiryRefreshJob = scope.launch { + delay(refreshDelayMs) + refreshIdentityState() + } } fun isPeerVerified(peerID: String): Boolean { @@ -60,6 +91,18 @@ class VerificationHandler( return _verifiedFingerprints.value.contains(fingerprint) } + fun isFingerprintVouched(fingerprint: String): Boolean = + _vouchedFingerprints.value.contains(fingerprint.lowercase()) + + fun vouchersForFingerprint(fingerprint: String): List = + identityManager.validVouchers(fingerprint) + + fun voucherNamesForFingerprint(fingerprint: String): List = + identityManager.validVouchers(fingerprint).map { record -> + identityManager.getCachedFingerprintNickname(record.voucherFingerprint) + ?: record.voucherFingerprint.take(FINGERPRINT_NAME_PREFIX_LENGTH) + } + fun unverifyFingerprint(peerID: String) { val fingerprint = meshService.getPeerFingerprint(peerID) ?: return identityManager.setVerifiedFingerprint(fingerprint, false) diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index f690b215..7f4fb793 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -107,6 +107,14 @@ object AppConstants { // Relay subscription validation const val SUBSCRIPTION_VALIDATION_INTERVAL_MS: Long = 30_000L + + // Client-side timestamp windows for inbound DMs (iOS TransportConfig parity). + // Inner rumor created_at is the sender's true send time; outer gift-wrap + // created_at is NIP-17-randomized into the past and may be older. + const val DM_SUBSCRIBE_LOOKBACK_SECONDS: Long = 86_400L // 24h + const val DM_MAX_CLOCK_SKEW_SECONDS: Long = 900L // 15min + // Outer gift-wrap age ceiling: 48h randomization + 15min skew. + const val DM_GIFT_WRAP_MAX_AGE_SECONDS: Long = 173_700L } object Tor { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ff033fa..049c94db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -519,11 +519,17 @@ Handshake pending Open a private chat to view fingerprints Encrypted & verified + Encrypted & vouched Encrypted Handshaking Handshake failed Not encrypted Verified + Vouched + + Vouched for by %1$d person you verified + Vouched for by %1$d people you verified + You have verified this person\'s identity. Not verified Compare these fingerprints with %1$s using a secure channel. diff --git a/app/src/test/kotlin/com/bitchat/android/identity/VouchPersistenceTest.kt b/app/src/test/kotlin/com/bitchat/android/identity/VouchPersistenceTest.kt new file mode 100644 index 00000000..3bbd27cd --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/identity/VouchPersistenceTest.kt @@ -0,0 +1,187 @@ +package com.bitchat.android.identity + +import android.content.Context +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.VouchAttestation +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class VouchPersistenceTest { + private lateinit var manager: SecureIdentityStateManager + private lateinit var prefs: android.content.SharedPreferences + private val voucher = fingerprint(VOUCHER_INDEX) + private val vouchee = fingerprint(VOUCHEE_INDEX) + private val voucheeSigningKey = ByteArray(VouchAttestation.SIGNING_KEY_SIZE) { + VOUCHEE_SIGNING_KEY_BYTE + } + + @Before + fun setup() { + prefs = RuntimeEnvironment.getApplication().getSharedPreferences( + "$PREFS_PREFIX${UUID.randomUUID()}", + Context.MODE_PRIVATE + ) + manager = SecureIdentityStateManager(prefs, testOnly = true) + manager.clearIdentityData() + manager.setVerifiedFingerprint(voucher, true) + manager.storeAuthenticatedPeerState( + vouchee, + AuthenticatedPeerState(PeerCapabilities.VOUCH, voucheeSigningKey) + ) + } + + @After + fun tearDown() = manager.clearIdentityData() + + @Test + fun `vouch persists and derives trust only while voucher remains verified`() { + assertTrue( + manager.recordVouch(vouchee, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + ) + assertTrue(manager.isVouched(vouchee, TEST_NOW_MS)) + assertTrue(SecureIdentityStateManager(prefs, testOnly = true).isVouched(vouchee, TEST_NOW_MS)) + + manager.setVerifiedFingerprint(voucher, false) + assertFalse(manager.isVouched(vouchee, TEST_NOW_MS)) + manager.setVerifiedFingerprint(voucher, true) + assertTrue(manager.isVouched(vouchee, TEST_NOW_MS)) + } + + @Test + fun `storage rejects self verified stale and future vouches`() { + assertFalse( + manager.recordVouch(voucher, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + ) + manager.setVerifiedFingerprint(vouchee, true) + assertFalse( + manager.recordVouch(vouchee, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + ) + manager.setVerifiedFingerprint(vouchee, false) + assertFalse( + manager.recordVouch( + vouchee, + voucher, + voucheeSigningKey, + TEST_NOW_MS - VouchAttestation.MAX_AGE_MS - INVALID_TIME_DELTA_MS, + TEST_NOW_MS + ) + ) + assertFalse( + manager.recordVouch( + vouchee, + voucher, + voucheeSigningKey, + TEST_NOW_MS + VouchAttestation.MAX_CLOCK_SKEW_MS + INVALID_TIME_DELTA_MS, + TEST_NOW_MS + ) + ) + } + + @Test + fun `only the most recent bounded voucher set is retained`() { + repeat(SecureIdentityStateManager.MAX_VOUCHERS_PER_VOUCHEE + EXTRA_VOUCHER_COUNT) { index -> + val candidate = fingerprint(index + FIRST_GENERATED_VOUCHER_INDEX) + manager.setVerifiedFingerprint(candidate, true) + manager.recordVouch( + vouchee, + candidate, + voucheeSigningKey, + TEST_NOW_MS + index, + TEST_NOW_MS + ) + } + + val records = manager.validVouchers(vouchee, TEST_NOW_MS) + assertEquals(SecureIdentityStateManager.MAX_VOUCHERS_PER_VOUCHEE, records.size) + assertFalse(records.any { it.voucherFingerprint == fingerprint(FIRST_GENERATED_VOUCHER_INDEX) }) + } + + @Test + fun `vouch only counts for the attested authenticated signing key`() { + assertTrue( + manager.recordVouch(vouchee, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + ) + assertTrue(manager.isVouched(vouchee, TEST_NOW_MS)) + + manager.storeAuthenticatedPeerState( + vouchee, + AuthenticatedPeerState(PeerCapabilities.VOUCH, rotatedSigningKey()) + ) + assertFalse(manager.isVouched(vouchee, TEST_NOW_MS)) + + manager.storeAuthenticatedPeerState( + vouchee, + AuthenticatedPeerState(PeerCapabilities.VOUCH, voucheeSigningKey) + ) + assertTrue(manager.isVouched(vouchee, TEST_NOW_MS)) + } + + @Test + fun `next expiry tracks when derived trust must refresh`() { + manager.recordVouch(vouchee, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + val expectedExpiry = TEST_NOW_MS + VouchAttestation.MAX_AGE_MS + EXPIRY_TRANSITION_OFFSET_MS + + assertEquals(expectedExpiry, manager.nextVouchExpiryMs(TEST_NOW_MS)) + assertEquals(null, manager.nextVouchExpiryMs(expectedExpiry)) + } + + @Test + fun `rate limit and vouch graph clear with panic wipe`() { + manager.markVouchBatchSent(voucher, TEST_NOW_MS) + manager.recordVouch(vouchee, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + assertEquals(TEST_NOW_MS, manager.lastVouchBatchSent(voucher)) + + manager.clearIdentityData() + assertEquals(null, manager.lastVouchBatchSent(voucher)) + assertTrue(manager.validVouchers(vouchee, TEST_NOW_MS).isEmpty()) + } + + @Test + fun `manager surviving panic cannot recreate vouch state`() { + val wipingManager = SecureIdentityStateManager(prefs, testOnly = true) + wipingManager.clearIdentityData() + + assertFalse( + manager.recordVouch(vouchee, voucher, voucheeSigningKey, TEST_NOW_MS, TEST_NOW_MS) + ) + manager.markVouchBatchSent(voucher, TEST_NOW_MS) + + val reloaded = SecureIdentityStateManager(prefs, testOnly = true) + assertTrue(reloaded.validVouchers(vouchee, TEST_NOW_MS).isEmpty()) + assertEquals(null, reloaded.lastVouchBatchSent(voucher)) + } + + private fun rotatedSigningKey() = ByteArray(VouchAttestation.SIGNING_KEY_SIZE) { + ROTATED_SIGNING_KEY_BYTE + } + + private fun fingerprint(index: Int): String = + index.toString(HEX_RADIX).padStart(FINGERPRINT_HEX_LENGTH, FINGERPRINT_PAD_CHAR) + .takeLast(FINGERPRINT_HEX_LENGTH) + + companion object { + private const val PREFS_PREFIX = "vouch-persistence-" + private const val VOUCHER_INDEX = 1 + private const val VOUCHEE_INDEX = 2 + private const val FIRST_GENERATED_VOUCHER_INDEX = 10 + private const val EXTRA_VOUCHER_COUNT = 2 + private const val INVALID_TIME_DELTA_MS = 1L + private const val EXPIRY_TRANSITION_OFFSET_MS = 1L + private const val VOUCHEE_SIGNING_KEY_BYTE: Byte = 0x31 + private const val ROTATED_SIGNING_KEY_BYTE: Byte = 0x32 + private const val TEST_NOW_MS = 1_700_000_000_000L + private const val HEX_RADIX = 16 + private const val FINGERPRINT_HEX_LENGTH = VouchAttestation.FINGERPRINT_SIZE * 2 + private const val FINGERPRINT_PAD_CHAR = '0' + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/VouchCoordinatorTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/VouchCoordinatorTest.kt new file mode 100644 index 00000000..251130c7 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/VouchCoordinatorTest.kt @@ -0,0 +1,133 @@ +package com.bitchat.android.mesh + +import android.content.Context +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.VouchAttestation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class VouchCoordinatorTest { + private lateinit var identity: SecureIdentityStateManager + private lateinit var scope: CoroutineScope + private val sentPayloads = mutableListOf() + private val peerFingerprint = fingerprint(PEER_FINGERPRINT_INDEX) + private val voucheeFingerprint = fingerprint(VOUCHEE_FINGERPRINT_INDEX) + private var capabilities = PeerCapabilities.VOUCH + + @Before + fun setup() { + val prefs = RuntimeEnvironment.getApplication().getSharedPreferences( + "$PREFS_PREFIX${UUID.randomUUID()}", + Context.MODE_PRIVATE + ) + identity = SecureIdentityStateManager(prefs, testOnly = true) + identity.clearIdentityData() + identity.setVerifiedFingerprint(peerFingerprint, true) + identity.setVerifiedFingerprint(voucheeFingerprint, true) + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + } + + @After + fun tearDown() { + scope.cancel() + identity.clearIdentityData() + } + + @Test + fun `send policy excludes recipient and persists interval`() { + val coordinator = coordinator() + + assertTrue(coordinator.attemptVouch(PEER_ID, peerFingerprint, TEST_NOW_MS)) + val firstBatch = VouchAttestation.decodeList(sentPayloads.single()) + assertEquals(SINGLE_ATTESTATION_COUNT, firstBatch.size) + assertEquals(voucheeFingerprint, firstBatch.single().voucheeFingerprint.toHex()) + assertFalse( + coordinator.attemptVouch( + PEER_ID, + peerFingerprint, + TEST_NOW_MS + VouchCoordinator.BATCH_INTERVAL_MS - BEFORE_INTERVAL_DELTA_MS + ) + ) + assertTrue( + coordinator.attemptVouch( + PEER_ID, + peerFingerprint, + TEST_NOW_MS + VouchCoordinator.BATCH_INTERVAL_MS + ) + ) + } + + @Test + fun `unsupported capability blocks send but unknown remains race tolerant`() { + capabilities = PeerCapabilities.PRIVATE_MEDIA + assertFalse(coordinator().attemptVouch(PEER_ID, peerFingerprint, TEST_NOW_MS)) + + capabilities = PeerCapabilities.NONE + assertTrue(coordinator().attemptVouch(PEER_ID, peerFingerprint, TEST_NOW_MS)) + } + + private fun coordinator() = VouchCoordinator( + scope = scope, + identity = identity, + connectedPeerIDs = { listOf(PEER_ID) }, + fingerprintForPeer = { peerFingerprint }, + peerInfo = { + PeerInfo( + id = PEER_ID, + nickname = PEER_NICKNAME, + isConnected = true, + isDirectConnection = true, + noisePublicKey = ByteArray(VouchAttestation.FINGERPRINT_SIZE), + signingPublicKey = ByteArray(VouchAttestation.SIGNING_KEY_SIZE), + isVerifiedNickname = true, + lastSeen = TEST_NOW_MS, + capabilities = capabilities + ) + }, + signingKeyForFingerprint = { ByteArray(VouchAttestation.SIGNING_KEY_SIZE) }, + hasEstablishedSession = { true }, + sign = { ByteArray(VouchAttestation.SIGNATURE_SIZE) }, + verify = { _, _, _ -> true }, + send = { _, payload -> sentPayloads.add(payload) } + ) + + private fun fingerprint(index: Int): String = + index.toString(HEX_RADIX) + .padStart(FINGERPRINT_HEX_LENGTH, FINGERPRINT_PAD_CHAR) + .takeLast(FINGERPRINT_HEX_LENGTH) + + private fun ByteArray.toHex(): String = joinToString(HEX_SEPARATOR) { + HEX_BYTE_FORMAT.format(it.toInt() and BYTE_MASK) + } + + companion object { + private const val PREFS_PREFIX = "vouch-coordinator-" + private const val PEER_ID = "peer-id" + private const val PEER_NICKNAME = "peer" + private const val PEER_FINGERPRINT_INDEX = 1 + private const val VOUCHEE_FINGERPRINT_INDEX = 2 + private const val SINGLE_ATTESTATION_COUNT = 1 + private const val BEFORE_INTERVAL_DELTA_MS = 1L + private const val TEST_NOW_MS = 1_700_000_000_000L + private const val HEX_RADIX = 16 + private const val FINGERPRINT_HEX_LENGTH = VouchAttestation.FINGERPRINT_SIZE * 2 + private const val FINGERPRINT_PAD_CHAR = '0' + private const val HEX_SEPARATOR = "" + private const val HEX_BYTE_FORMAT = "%02x" + private const val BYTE_MASK = 0xFF + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt index f945a59f..f7d53285 100644 --- a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -92,11 +92,12 @@ class IdentityAnnouncementTest { val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!! assertArrayEquals( - byteArrayOf(0x05, 0x02, 0x18, 0x01), + byteArrayOf(0x05, 0x02, 0x38, 0x01), encoded.takeLast(4).toByteArray() ) val capabilities = IdentityAnnouncement.decode(encoded)!!.capabilities!! assertTrue(capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) assertTrue(capabilities.contains(PeerCapabilities.GROUPS)) + assertTrue(capabilities.contains(PeerCapabilities.VOUCH)) } } diff --git a/app/src/test/kotlin/com/bitchat/android/model/VouchAttestationTest.kt b/app/src/test/kotlin/com/bitchat/android/model/VouchAttestationTest.kt new file mode 100644 index 00000000..d351d345 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/VouchAttestationTest.kt @@ -0,0 +1,89 @@ +package com.bitchat.android.model + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.SecureRandom + +class VouchAttestationTest { + private val privateKey = Ed25519PrivateKeyParameters(SecureRandom()) + private val publicKey: Ed25519PublicKeyParameters = privateKey.generatePublicKey() + private val fingerprint = ByteArray(VouchAttestation.FINGERPRINT_SIZE) { FINGERPRINT_BYTE } + private val voucheeKey = ByteArray(VouchAttestation.SIGNING_KEY_SIZE) { SIGNING_KEY_BYTE } + + @Test + fun `attestation round trips with iOS wire format and verifies`() { + val attestation = buildAttestation() + val decoded = VouchAttestation.decode(attestation.encode())!! + + assertEquals(attestation, decoded) + assertTrue(verify(decoded.signature, decoded.signableBytes(), publicKey.encoded)) + assertArrayEquals(fingerprint, decoded.voucheeFingerprint) + } + + @Test + fun `unknown TLV is skipped but truncation is rejected`() { + val encoded = buildAttestation().encode() + val withUnknown = encoded + byteArrayOf(UNKNOWN_TLV_TYPE, UNKNOWN_TLV_LENGTH, UNKNOWN_TLV_VALUE) + + assertEquals(buildAttestation(), VouchAttestation.decode(withUnknown)) + assertEquals(null, VouchAttestation.decode(encoded.copyOf(encoded.size - TRUNCATED_BYTE_COUNT))) + } + + @Test + fun `tampering and expiry are rejected`() { + val attestation = buildAttestation() + val tampered = attestation.signableBytes().copyOf().also { + it[it.lastIndex] = (it.last().toInt() xor TAMPER_MASK).toByte() + } + assertFalse(verify(attestation.signature, tampered, publicKey.encoded)) + assertTrue(attestation.isExpired(TEST_TIMESTAMP_MS + VouchAttestation.MAX_AGE_MS + EXPIRY_DELTA_MS)) + assertTrue(attestation.isExpired(TEST_TIMESTAMP_MS - VouchAttestation.MAX_CLOCK_SKEW_MS - EXPIRY_DELTA_MS)) + } + + @Test + fun `batch caps encoding and decoding`() { + val attestations = List(VouchAttestation.MAX_BATCH_COUNT) { buildAttestation() } + val encoded = VouchAttestation.encodeList(attestations)!! + assertEquals(VouchAttestation.MAX_BATCH_COUNT, VouchAttestation.decodeList(encoded).size) + assertEquals(null, VouchAttestation.encodeList(attestations + buildAttestation())) + + val dishonestCount = encoded.copyOf().also { it[BATCH_COUNT_OFFSET] = UByte.MAX_VALUE.toByte() } + assertEquals(VouchAttestation.MAX_BATCH_COUNT, VouchAttestation.decodeList(dishonestCount).size) + } + + private fun buildAttestation(): VouchAttestation = + requireNotNull(VouchAttestation.build(fingerprint, voucheeKey, TEST_TIMESTAMP_MS, ::sign)) + + private fun sign(data: ByteArray): ByteArray { + val signer = Ed25519Signer() + signer.init(true, privateKey) + signer.update(data, 0, data.size) + return signer.generateSignature() + } + + private fun verify(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean { + val verifier = Ed25519Signer() + verifier.init(false, Ed25519PublicKeyParameters(publicKey, 0)) + verifier.update(data, 0, data.size) + return verifier.verifySignature(signature) + } + + companion object { + private const val TEST_TIMESTAMP_MS = 1_700_000_000_000L + private const val FINGERPRINT_BYTE: Byte = 0x11 + private const val SIGNING_KEY_BYTE: Byte = 0x22 + private const val UNKNOWN_TLV_TYPE: Byte = 0x7F + private const val UNKNOWN_TLV_LENGTH: Byte = 0x01 + private const val UNKNOWN_TLV_VALUE: Byte = 0x42 + private const val TRUNCATED_BYTE_COUNT = 1 + private const val TAMPER_MASK = 0x01 + private const val EXPIRY_DELTA_MS = 1L + private const val BATCH_COUNT_OFFSET = 0 + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrTimestampPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrTimestampPolicyTest.kt new file mode 100644 index 00000000..c1411956 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrTimestampPolicyTest.kt @@ -0,0 +1,67 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.util.AppConstants +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NostrTimestampPolicyTest { + + private val now = 1_700_000_000L + private val skew = AppConstants.Nostr.DM_MAX_CLOCK_SKEW_SECONDS + private val lookback = AppConstants.Nostr.DM_SUBSCRIBE_LOOKBACK_SECONDS + private val giftWrapMax = AppConstants.Nostr.DM_GIFT_WRAP_MAX_AGE_SECONDS + + @Test + fun `rumor timestamp at now is accepted`() { + assertTrue(NostrTimestampPolicy.isPlausibleRumorTimestamp(now.toInt(), now)) + } + + @Test + fun `rumor timestamp within lookback is accepted`() { + val ts = (now - lookback).toInt() + assertTrue(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `rumor timestamp just inside skew past lookback is accepted`() { + val ts = (now - lookback - skew).toInt() + assertTrue(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `rumor timestamp older than lookback plus skew is rejected`() { + val ts = (now - lookback - skew - 1).toInt() + assertFalse(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `future-dated rumor within skew is accepted`() { + val ts = (now + skew).toInt() + assertTrue(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `future-dated rumor beyond skew is rejected`() { + val ts = (now + skew + 1).toInt() + assertFalse(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `future-dated gift wrap beyond skew is rejected`() { + val createdAt = (now + skew + 1).toInt() + assertFalse(NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(createdAt, now)) + } + + @Test + fun `gift wrap within randomization ceiling is accepted`() { + val createdAt = (now - giftWrapMax).toInt() + assertTrue(NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(createdAt, now)) + } + + @Test + fun `gift wrap older than randomization ceiling is rejected`() { + val createdAt = (now - giftWrapMax - 1).toInt() + assertFalse(NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(createdAt, now)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/VerificationServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/services/VerificationServiceTest.kt new file mode 100644 index 00000000..7e24518d --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/VerificationServiceTest.kt @@ -0,0 +1,70 @@ +package com.bitchat.android.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.util.hexEncodedString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VerificationServiceTest { + private lateinit var encryptionService: EncryptionService + + @Before + fun setup() { + val context: Context = ApplicationProvider.getApplicationContext() + encryptionService = EncryptionService(context) + VerificationService.configure(encryptionService) + } + + @Test + fun `freshness check rejects both stale and future-dated timestamps`() { + assertEquals(Long.MAX_VALUE, verificationTimestampSkewSeconds(1_700_000_000L, Long.MIN_VALUE)) + assertEquals(Long.MAX_VALUE - 1_700_000_000L, verificationTimestampSkewSeconds(1_700_000_000L, Long.MAX_VALUE)) + assertEquals(0L, verificationTimestampSkewSeconds(1_700_000_000L, 1_700_000_000L)) + assertEquals(60L, verificationTimestampSkewSeconds(1_700_000_060L, 1_700_000_000L)) + assertEquals(3_600L, verificationTimestampSkewSeconds(1_700_000_000L, 1_700_003_600L)) + } + + @Test + fun `verifyScannedQR accepts a freshly signed payload`() { + val qr = VerificationService.buildMyQRString(nickname = "alice", npub = null) + assertNotNull(qr) + assertNotNull(VerificationService.verifyScannedQR(qr!!, maxAgeSeconds = 60)) + } + + @Test + fun `verifyScannedQR rejects a future-dated payload`() { + val qr = signedQr(ts = (System.currentTimeMillis() / 1000L) + 3_600L) + assertNull(VerificationService.verifyScannedQR(qr, maxAgeSeconds = 60)) + } + + @Test + fun `verifyScannedQR rejects an expired payload`() { + val qr = signedQr(ts = (System.currentTimeMillis() / 1000L) - 3_600L) + assertNull(VerificationService.verifyScannedQR(qr, maxAgeSeconds = 60)) + } + + private fun signedQr(ts: Long): String { + val noise = encryptionService.getStaticPublicKey()!!.joinToString("") { "%02x".format(it) } + val sign = encryptionService.getSigningPublicKey()!!.joinToString("") { "%02x".format(it) } + val payload = VerificationService.VerificationQR( + v = 1, + noiseKeyHex = noise, + signKeyHex = sign, + npub = null, + nickname = "future-alice", + ts = ts, + nonceB64 = "AAAAAAAAAAAAAAAAAAAAAA", + sigHex = "" + ) + val signature = encryptionService.signData(payload.canonicalBytes())!! + return payload.copy(sigHex = signature.hexEncodedString()).toUrlString() + } +}