Add authenticated vouching and bound verification timestamps

This commit is contained in:
callebtc 2026-09-07 23:42:19 +03:00
parent 85f707932d
commit 6cc437d172
24 changed files with 1417 additions and 34 deletions

View File

@ -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<Unit>(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<VouchRecord> {
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<String> =
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<String> {
return synchronized(identityPersistenceLock) {
val verifiedAt = readTimestampMap(KEY_VERIFIED_AT)
getVerifiedFingerprints()
.map { it.lowercase() }
.filterNot { it == excluding.lowercase() }
.sortedWith(compareByDescending<String> { 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<String, Long> =
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<String, Long>): Set<String> =
values.mapTo(mutableSetOf()) { (fingerprint, timestamp) ->
"$fingerprint$RECORD_SEPARATOR$timestamp"
}
private fun readVouchRecords(): Map<String, List<VouchRecord>> =
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<String, List<VouchRecord>>): Set<String> =
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) {

View File

@ -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<String>) {
// 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()

View File

@ -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<VoiceFrameRequest>(capacity = 128)
private val directPeers = ConcurrentHashMap.newKeySet<String>()
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<String>) {
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

View File

@ -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) {}
}

View File

@ -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<String>,
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<String>) {
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
}
}

View File

@ -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);

View File

@ -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

View File

@ -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<VouchAttestation>): 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<VouchAttestation> {
if (data.size <= BATCH_COUNT_SIZE) return emptyList()
val limit = minOf(data[0].toInt() and BYTE_MASK, MAX_BATCH_COUNT)
val decoded = mutableListOf<VouchAttestation>()
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)
}
}
}

View File

@ -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 {

View File

@ -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.
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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<String> =
verificationHandler.voucherNamesForFingerprint(fingerprint)
fun unverifyFingerprint(peerID: String) {
verificationHandler.unverifyFingerprint(peerID)
}

View File

@ -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(

View File

@ -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<String>,
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),

View File

@ -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<Set<String>>(emptySet())
val verifiedFingerprints: StateFlow<Set<String>> = _verifiedFingerprints.asStateFlow()
private val _vouchedFingerprints = MutableStateFlow<Set<String>>(emptySet())
val vouchedFingerprints: StateFlow<Set<String>> = _vouchedFingerprints.asStateFlow()
private val pendingQRVerifications = ConcurrentHashMap<String, PendingVerification>()
private val lastVerifyNonceByPeer = ConcurrentHashMap<String, ByteArray>()
private val lastInboundVerifyChallengeAt = ConcurrentHashMap<String, Long>()
private val lastMutualToastAt = ConcurrentHashMap<String, Long>()
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<SecureIdentityStateManager.VouchRecord> =
identityManager.validVouchers(fingerprint)
fun voucherNamesForFingerprint(fingerprint: String): List<String> =
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)

View File

@ -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 {

View File

@ -519,11 +519,17 @@
<string name="fingerprint_pending">Handshake pending</string>
<string name="fingerprint_no_peer">Open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">Encrypted &amp; verified</string>
<string name="fingerprint_status_vouched" tools:ignore="MissingTranslation">Encrypted &amp; vouched</string>
<string name="fingerprint_status_encrypted">Encrypted</string>
<string name="fingerprint_status_handshaking">Handshaking</string>
<string name="fingerprint_status_failed">Handshake failed</string>
<string name="fingerprint_status_uninitialized">Not encrypted</string>
<string name="fingerprint_verified_label">Verified</string>
<string name="fingerprint_vouched_label" tools:ignore="MissingTranslation">Vouched</string>
<plurals name="fingerprint_vouched_message" tools:ignore="MissingTranslation">
<item quantity="one">Vouched for by %1$d person you verified</item>
<item quantity="other">Vouched for by %1$d people you verified</item>
</plurals>
<string name="fingerprint_verified_message">You have verified this person\'s identity.</string>
<string name="fingerprint_not_verified_label">Not verified</string>
<string name="fingerprint_not_verified_message_fmt">Compare these fingerprints with %1$s using a secure channel.</string>

View File

@ -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'
}
}

View File

@ -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<ByteArray>()
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
}
}

View File

@ -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))
}
}

View File

@ -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
}
}

View File

@ -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))
}
}

View File

@ -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()
}
}