mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Add authenticated vouching and bound verification timestamps
This commit is contained in:
parent
85f707932d
commit
6cc437d172
@ -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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
|
||||
127
app/src/main/java/com/bitchat/android/mesh/VouchCoordinator.kt
Normal file
127
app/src/main/java/com/bitchat/android/mesh/VouchCoordinator.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
193
app/src/main/java/com/bitchat/android/model/VouchAttestation.kt
Normal file
193
app/src/main/java/com/bitchat/android/model/VouchAttestation.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 & verified</string>
|
||||
<string name="fingerprint_status_vouched" tools:ignore="MissingTranslation">Encrypted & 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>
|
||||
|
||||
@ -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'
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user