mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 21:25:20 +00:00
noise
This commit is contained in:
@@ -76,6 +76,9 @@ dependencies {
|
|||||||
// Cryptography
|
// Cryptography
|
||||||
implementation(libs.bundles.cryptography)
|
implementation(libs.bundles.cryptography)
|
||||||
|
|
||||||
|
// Noise Protocol Framework
|
||||||
|
implementation("org.signal.forks:noise-java:0.1.1")
|
||||||
|
|
||||||
// JSON
|
// JSON
|
||||||
implementation(libs.gson)
|
implementation(libs.gson)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package com.bitchat.android.identity
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Static identity key persistence across app sessions
|
||||||
|
* - Peer ID rotation timing (5-15 minute random intervals)
|
||||||
|
* - Secure storage using Android EncryptedSharedPreferences
|
||||||
|
* - Fingerprint calculation and identity validation
|
||||||
|
*/
|
||||||
|
class SecureIdentityStateManager(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SecureIdentityStateManager"
|
||||||
|
private const val PREFS_NAME = "bitchat_identity"
|
||||||
|
private const val KEY_STATIC_PRIVATE_KEY = "static_private_key"
|
||||||
|
private const val KEY_STATIC_PUBLIC_KEY = "static_public_key"
|
||||||
|
private const val KEY_LAST_ROTATION = "last_rotation"
|
||||||
|
private const val KEY_NEXT_ROTATION_INTERVAL = "next_rotation_interval"
|
||||||
|
|
||||||
|
// Rotation intervals (same as iOS)
|
||||||
|
private const val MIN_ROTATION_INTERVAL = 5 * 60 * 1000L // 5 minutes
|
||||||
|
private const val MAX_ROTATION_INTERVAL = 15 * 60 * 1000L // 15 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences
|
||||||
|
private val random = SecureRandom()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Create master key for encryption
|
||||||
|
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Create encrypted shared preferences
|
||||||
|
prefs = EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
PREFS_NAME,
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Static Key Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load saved static key pair
|
||||||
|
* Returns (privateKey, publicKey) or null if none exists
|
||||||
|
*/
|
||||||
|
fun loadStaticKey(): Pair<ByteArray, ByteArray>? {
|
||||||
|
return try {
|
||||||
|
val privateKeyString = prefs.getString(KEY_STATIC_PRIVATE_KEY, null)
|
||||||
|
val publicKeyString = prefs.getString(KEY_STATIC_PUBLIC_KEY, null)
|
||||||
|
|
||||||
|
if (privateKeyString != null && publicKeyString != null) {
|
||||||
|
val privateKey = android.util.Base64.decode(privateKeyString, android.util.Base64.DEFAULT)
|
||||||
|
val publicKey = android.util.Base64.decode(publicKeyString, android.util.Base64.DEFAULT)
|
||||||
|
|
||||||
|
// Validate key sizes
|
||||||
|
if (privateKey.size == 32 && publicKey.size == 32) {
|
||||||
|
Log.d(TAG, "Loaded static identity key from secure storage")
|
||||||
|
Pair(privateKey, publicKey)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Invalid key sizes in storage, returning null")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "No static identity key found in storage")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load static key: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save static key pair to secure storage
|
||||||
|
*/
|
||||||
|
fun saveStaticKey(privateKey: ByteArray, publicKey: ByteArray) {
|
||||||
|
try {
|
||||||
|
// Validate key sizes
|
||||||
|
if (privateKey.size != 32 || publicKey.size != 32) {
|
||||||
|
throw IllegalArgumentException("Invalid key sizes: private=${privateKey.size}, public=${publicKey.size}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val privateKeyString = android.util.Base64.encodeToString(privateKey, android.util.Base64.DEFAULT)
|
||||||
|
val publicKeyString = android.util.Base64.encodeToString(publicKey, android.util.Base64.DEFAULT)
|
||||||
|
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_STATIC_PRIVATE_KEY, privateKeyString)
|
||||||
|
.putString(KEY_STATIC_PUBLIC_KEY, publicKeyString)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
Log.d(TAG, "Saved static identity key to secure storage")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to save static key: ${e.message}")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fingerprint Generation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate fingerprint from public key (SHA-256 hash)
|
||||||
|
*/
|
||||||
|
fun generateFingerprint(publicKeyData: ByteArray): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
val hash = digest.digest(publicKeyData)
|
||||||
|
return hash.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate fingerprint format
|
||||||
|
*/
|
||||||
|
fun isValidFingerprint(fingerprint: String): Boolean {
|
||||||
|
// SHA-256 fingerprint should be 64 hex characters
|
||||||
|
return fingerprint.matches(Regex("^[a-fA-F0-9]{64}$"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Peer ID Rotation Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if peer ID should be rotated based on random interval
|
||||||
|
*/
|
||||||
|
fun shouldRotatePeerID(): Boolean {
|
||||||
|
val lastRotation = prefs.getLong(KEY_LAST_ROTATION, 0L)
|
||||||
|
val nextInterval = prefs.getLong(KEY_NEXT_ROTATION_INTERVAL, 0L)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
if (lastRotation == 0L || nextInterval == 0L) {
|
||||||
|
// First run or missing data - schedule next rotation and don't rotate now
|
||||||
|
scheduleNextRotation()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val shouldRotate = (now - lastRotation) >= nextInterval
|
||||||
|
if (shouldRotate) {
|
||||||
|
Log.d(TAG, "Peer ID rotation due: ${(now - lastRotation) / 1000}s since last rotation")
|
||||||
|
}
|
||||||
|
|
||||||
|
return shouldRotate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark rotation as completed and schedule next one
|
||||||
|
*/
|
||||||
|
fun markRotationCompleted() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
prefs.edit()
|
||||||
|
.putLong(KEY_LAST_ROTATION, now)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
scheduleNextRotation()
|
||||||
|
|
||||||
|
Log.d(TAG, "Peer ID rotation marked as completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule the next rotation with random interval (5-15 minutes)
|
||||||
|
*/
|
||||||
|
private fun scheduleNextRotation() {
|
||||||
|
val nextInterval = MIN_ROTATION_INTERVAL + random.nextLong(MAX_ROTATION_INTERVAL - MIN_ROTATION_INTERVAL)
|
||||||
|
|
||||||
|
prefs.edit()
|
||||||
|
.putLong(KEY_NEXT_ROTATION_INTERVAL, nextInterval)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
Log.d(TAG, "Next peer ID rotation scheduled in ${nextInterval / 60000} minutes")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get time until next rotation (for debugging)
|
||||||
|
*/
|
||||||
|
fun getTimeUntilNextRotation(): Long {
|
||||||
|
val lastRotation = prefs.getLong(KEY_LAST_ROTATION, 0L)
|
||||||
|
val nextInterval = prefs.getLong(KEY_NEXT_ROTATION_INTERVAL, 0L)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
if (lastRotation == 0L || nextInterval == 0L) return -1
|
||||||
|
|
||||||
|
val elapsed = now - lastRotation
|
||||||
|
return maxOf(0L, nextInterval - elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Identity Validation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a public key is valid for Curve25519
|
||||||
|
*/
|
||||||
|
fun validatePublicKey(publicKey: ByteArray): Boolean {
|
||||||
|
if (publicKey.size != 32) return false
|
||||||
|
|
||||||
|
// Check for all-zero key (invalid point)
|
||||||
|
if (publicKey.all { it == 0.toByte() }) return false
|
||||||
|
|
||||||
|
// Check for other known invalid points
|
||||||
|
val invalidPoints = setOf(
|
||||||
|
ByteArray(32) { 0x00.toByte() }, // All zeros
|
||||||
|
ByteArray(32) { 0xFF.toByte() }, // All ones
|
||||||
|
// Add other known invalid Curve25519 points if needed
|
||||||
|
)
|
||||||
|
|
||||||
|
return !invalidPoints.any { it.contentEquals(publicKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a private key is valid for Curve25519
|
||||||
|
*/
|
||||||
|
fun validatePrivateKey(privateKey: ByteArray): Boolean {
|
||||||
|
if (privateKey.size != 32) return false
|
||||||
|
|
||||||
|
// Check for all-zero key
|
||||||
|
if (privateKey.all { it == 0.toByte() }) return false
|
||||||
|
|
||||||
|
// Check that clamping bits are correct for Curve25519
|
||||||
|
val clampedKey = privateKey.clone()
|
||||||
|
clampedKey[0] = (clampedKey[0].toInt() and 248).toByte()
|
||||||
|
clampedKey[31] = (clampedKey[31].toInt() and 127).toByte()
|
||||||
|
clampedKey[31] = (clampedKey[31].toInt() or 64).toByte()
|
||||||
|
|
||||||
|
// After clamping, the key should not be all zeros
|
||||||
|
return !clampedKey.all { it == 0.toByte() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug Information
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get debug information about identity state
|
||||||
|
*/
|
||||||
|
fun getDebugInfo(): String = buildString {
|
||||||
|
appendLine("=== Identity State Manager Debug ===")
|
||||||
|
|
||||||
|
val hasIdentity = prefs.contains(KEY_STATIC_PRIVATE_KEY)
|
||||||
|
appendLine("Has identity: $hasIdentity")
|
||||||
|
|
||||||
|
if (hasIdentity) {
|
||||||
|
val lastRotation = prefs.getLong(KEY_LAST_ROTATION, 0L)
|
||||||
|
val nextInterval = prefs.getLong(KEY_NEXT_ROTATION_INTERVAL, 0L)
|
||||||
|
val timeUntilNext = getTimeUntilNextRotation()
|
||||||
|
|
||||||
|
appendLine("Last rotation: ${if (lastRotation > 0) "${(System.currentTimeMillis() - lastRotation) / 1000}s ago" else "never"}")
|
||||||
|
appendLine("Next rotation in: ${if (timeUntilNext >= 0) "${timeUntilNext / 1000}s" else "not scheduled"}")
|
||||||
|
appendLine("Rotation interval: ${nextInterval / 1000}s")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val keyPair = loadStaticKey()
|
||||||
|
if (keyPair != null) {
|
||||||
|
val fingerprint = generateFingerprint(keyPair.second)
|
||||||
|
appendLine("Identity fingerprint: ${fingerprint.take(16)}...")
|
||||||
|
appendLine("Key validation: private=${validatePrivateKey(keyPair.first)}, public=${validatePublicKey(keyPair.second)}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appendLine("Key validation failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Emergency Clear
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all identity data (for panic mode)
|
||||||
|
*/
|
||||||
|
fun clearIdentityData() {
|
||||||
|
try {
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
Log.w(TAG, "All identity data cleared")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to clear identity data: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if identity data exists
|
||||||
|
*/
|
||||||
|
fun hasIdentityData(): Boolean {
|
||||||
|
return prefs.contains(KEY_STATIC_PRIVATE_KEY) && prefs.contains(KEY_STATIC_PUBLIC_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -226,8 +226,16 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
peerManager.updatePeerLastSeen(peerID)
|
peerManager.updatePeerLastSeen(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleKeyExchange(routed: RoutedPacket): Boolean {
|
override fun handleNoiseHandshake(routed: RoutedPacket, step: Int): Boolean {
|
||||||
return runBlocking { securityManager.handleKeyExchange(routed) }
|
return runBlocking { securityManager.handleNoiseHandshake(routed, step) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||||
|
serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleNoiseIdentityAnnouncement(routed: RoutedPacket) {
|
||||||
|
serviceScope.launch { messageHandler.handleNoiseIdentityAnnouncement(routed) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleAnnounce(routed: RoutedPacket) {
|
override fun handleAnnounce(routed: RoutedPacket) {
|
||||||
@@ -493,7 +501,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
private fun sendKeyExchangeToDevice() {
|
private fun sendKeyExchangeToDevice() {
|
||||||
val publicKeyData = securityManager.getCombinedPublicKeyData()
|
val publicKeyData = securityManager.getCombinedPublicKeyData()
|
||||||
val packet = BitchatPacket(
|
val packet = BitchatPacket(
|
||||||
type = MessageType.KEY_EXCHANGE.value,
|
type = MessageType.NOISE_HANDSHAKE_INIT.value,
|
||||||
ttl = 1u,
|
ttl = 1u,
|
||||||
senderID = myPeerID,
|
senderID = myPeerID,
|
||||||
payload = publicKeyData
|
payload = publicKeyData
|
||||||
|
|||||||
@@ -28,6 +28,30 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
// Coroutines
|
// Coroutines
|
||||||
private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Noise encrypted transport message (temporarily stubbed)
|
||||||
|
*/
|
||||||
|
suspend fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||||
|
val packet = routed.packet
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
|
|
||||||
|
Log.d(TAG, "TODO: Handle Noise encrypted message from $peerID (${packet.payload.size} bytes)")
|
||||||
|
|
||||||
|
// For now, just log it - this will be implemented with actual Noise protocol handling
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Noise identity announcement (temporarily stubbed)
|
||||||
|
*/
|
||||||
|
suspend fun handleNoiseIdentityAnnouncement(routed: RoutedPacket) {
|
||||||
|
val packet = routed.packet
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
|
|
||||||
|
Log.d(TAG, "TODO: Handle Noise identity announcement from $peerID (${packet.payload.size} bytes)")
|
||||||
|
|
||||||
|
// For now, just log it - this will be implemented with peer ID rotation handling
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle announce message
|
* Handle announce message
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ class PacketProcessor(private val myPeerID: String) {
|
|||||||
|
|
||||||
// Process based on message type (exact same logic as iOS)
|
// Process based on message type (exact same logic as iOS)
|
||||||
when (MessageType.fromValue(packet.type)) {
|
when (MessageType.fromValue(packet.type)) {
|
||||||
MessageType.KEY_EXCHANGE -> handleKeyExchange(routed)
|
MessageType.NOISE_HANDSHAKE_INIT -> handleNoiseHandshake(routed, 1)
|
||||||
|
MessageType.NOISE_HANDSHAKE_RESP -> handleNoiseHandshake(routed, 2)
|
||||||
|
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
|
||||||
|
MessageType.NOISE_IDENTITY_ANNOUNCE -> handleNoiseIdentityAnnouncement(routed)
|
||||||
MessageType.ANNOUNCE -> handleAnnounce(routed)
|
MessageType.ANNOUNCE -> handleAnnounce(routed)
|
||||||
MessageType.MESSAGE -> handleMessage(routed)
|
MessageType.MESSAGE -> handleMessage(routed)
|
||||||
MessageType.LEAVE -> handleLeave(routed)
|
MessageType.LEAVE -> handleLeave(routed)
|
||||||
@@ -67,16 +70,17 @@ class PacketProcessor(private val myPeerID: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle key exchange message
|
* Handle Noise handshake message
|
||||||
*/
|
*/
|
||||||
private suspend fun handleKeyExchange(routed: RoutedPacket) {
|
private suspend fun handleNoiseHandshake(routed: RoutedPacket, step: Int) {
|
||||||
val peerID = routed.peerID ?: "unknown"
|
val peerID = routed.peerID ?: "unknown"
|
||||||
Log.d(TAG, "Processing key exchange from $peerID")
|
Log.d(TAG, "Processing Noise handshake step $step from $peerID")
|
||||||
|
|
||||||
val success = delegate?.handleKeyExchange(routed) ?: false
|
val success = delegate?.handleNoiseHandshake(routed, step) ?: false
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Key exchange successful, send announce and cached messages
|
// Handshake successful, may need to send announce and cached messages
|
||||||
|
// This will be determined by the Noise implementation when session is established
|
||||||
delay(100)
|
delay(100)
|
||||||
delegate?.sendAnnouncementToPeer(peerID)
|
delegate?.sendAnnouncementToPeer(peerID)
|
||||||
|
|
||||||
@@ -85,6 +89,24 @@ class PacketProcessor(private val myPeerID: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Noise encrypted transport message
|
||||||
|
*/
|
||||||
|
private suspend fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
|
Log.d(TAG, "Processing Noise encrypted message from $peerID")
|
||||||
|
delegate?.handleNoiseEncrypted(routed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Noise identity announcement (after peer ID rotation)
|
||||||
|
*/
|
||||||
|
private suspend fun handleNoiseIdentityAnnouncement(routed: RoutedPacket) {
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
|
Log.d(TAG, "Processing Noise identity announcement from $peerID")
|
||||||
|
delegate?.handleNoiseIdentityAnnouncement(routed)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle announce message
|
* Handle announce message
|
||||||
*/
|
*/
|
||||||
@@ -174,7 +196,9 @@ interface PacketProcessorDelegate {
|
|||||||
fun updatePeerLastSeen(peerID: String)
|
fun updatePeerLastSeen(peerID: String)
|
||||||
|
|
||||||
// Message type handlers
|
// Message type handlers
|
||||||
fun handleKeyExchange(routed: RoutedPacket): Boolean
|
fun handleNoiseHandshake(routed: RoutedPacket, step: Int): Boolean
|
||||||
|
fun handleNoiseEncrypted(routed: RoutedPacket)
|
||||||
|
fun handleNoiseIdentityAnnouncement(routed: RoutedPacket)
|
||||||
fun handleAnnounce(routed: RoutedPacket)
|
fun handleAnnounce(routed: RoutedPacket)
|
||||||
fun handleMessage(routed: RoutedPacket)
|
fun handleMessage(routed: RoutedPacket)
|
||||||
fun handleLeave(routed: RoutedPacket)
|
fun handleLeave(routed: RoutedPacket)
|
||||||
|
|||||||
@@ -86,6 +86,19 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Noise handshake packet (temporarily stubbed for basic build)
|
||||||
|
*/
|
||||||
|
suspend fun handleNoiseHandshake(routed: RoutedPacket, step: Int): Boolean {
|
||||||
|
val packet = routed.packet
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
|
|
||||||
|
Log.d(TAG, "TODO: Handle Noise handshake step $step from $peerID (${packet.payload.size} bytes)")
|
||||||
|
|
||||||
|
// For now, just treat it as a key exchange for compatibility
|
||||||
|
return handleKeyExchange(routed)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle key exchange packet
|
* Handle key exchange packet
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ class StoreForwardManager {
|
|||||||
*/
|
*/
|
||||||
fun cacheMessage(packet: BitchatPacket, messageID: String) {
|
fun cacheMessage(packet: BitchatPacket, messageID: String) {
|
||||||
// Skip certain message types (same as iOS)
|
// Skip certain message types (same as iOS)
|
||||||
if (packet.type == MessageType.KEY_EXCHANGE.value ||
|
if (packet.type == MessageType.NOISE_HANDSHAKE_INIT.value ||
|
||||||
|
packet.type == MessageType.NOISE_HANDSHAKE_RESP.value ||
|
||||||
|
packet.type == MessageType.NOISE_ENCRYPTED.value ||
|
||||||
|
packet.type == MessageType.NOISE_IDENTITY_ANNOUNCE.value ||
|
||||||
packet.type == MessageType.ANNOUNCE.value ||
|
packet.type == MessageType.ANNOUNCE.value ||
|
||||||
packet.type == MessageType.LEAVE.value) {
|
packet.type == MessageType.LEAVE.value) {
|
||||||
Log.d(TAG, "Skipping cache for message type: ${packet.type}")
|
Log.d(TAG, "Skipping cache for message type: ${packet.type}")
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package com.bitchat.android.noise
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.SecretKeyFactory
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel encryption for password-protected channels - 100% compatible with iOS implementation
|
||||||
|
*
|
||||||
|
* Uses PBKDF2 key derivation with channel name as salt and AES-256-GCM for encryption.
|
||||||
|
* This is separate from Noise sessions and used for group channels with shared passwords.
|
||||||
|
*/
|
||||||
|
class NoiseChannelEncryption {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NoiseChannelEncryption"
|
||||||
|
|
||||||
|
// PBKDF2 parameters (same as iOS)
|
||||||
|
private const val PBKDF2_ITERATIONS = 100000
|
||||||
|
private const val KEY_LENGTH = 256 // 256-bit AES key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel keys storage (channelName -> AES key)
|
||||||
|
private val channelKeys = ConcurrentHashMap<String, SecretKeySpec>()
|
||||||
|
|
||||||
|
// Channel passwords (for rekey operations)
|
||||||
|
private val channelPasswords = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
// MARK: - Channel Password Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set password for a channel and derive encryption key
|
||||||
|
*/
|
||||||
|
fun setChannelPassword(password: String, channel: String) {
|
||||||
|
try {
|
||||||
|
if (password.isEmpty()) {
|
||||||
|
Log.w(TAG, "Empty password provided for channel $channel")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive key from password using PBKDF2 (same as iOS)
|
||||||
|
val key = deriveChannelKey(password, channel)
|
||||||
|
|
||||||
|
// Store key and password
|
||||||
|
channelKeys[channel] = key
|
||||||
|
channelPasswords[channel] = password
|
||||||
|
|
||||||
|
Log.d(TAG, "Set password for channel $channel")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to set password for channel $channel: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove password for a channel
|
||||||
|
*/
|
||||||
|
fun removeChannelPassword(channel: String) {
|
||||||
|
channelKeys.remove(channel)
|
||||||
|
channelPasswords.remove(channel)
|
||||||
|
Log.d(TAG, "Removed password for channel $channel")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we have a key for a channel
|
||||||
|
*/
|
||||||
|
fun hasChannelKey(channel: String): Boolean {
|
||||||
|
return channelKeys.containsKey(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get channel password (if available)
|
||||||
|
*/
|
||||||
|
fun getChannelPassword(channel: String): String? {
|
||||||
|
return channelPasswords[channel]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a message for a channel
|
||||||
|
* Returns encrypted data including IV
|
||||||
|
*/
|
||||||
|
fun encryptChannelMessage(message: String, channel: String): ByteArray {
|
||||||
|
val key = channelKeys[channel]
|
||||||
|
?: throw IllegalStateException("No key available for channel $channel")
|
||||||
|
|
||||||
|
val messageBytes = message.toByteArray(Charsets.UTF_8)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||||
|
|
||||||
|
val iv = cipher.iv
|
||||||
|
val encryptedData = cipher.doFinal(messageBytes)
|
||||||
|
|
||||||
|
// Combine IV and encrypted data (same format as iOS)
|
||||||
|
val result = ByteArray(iv.size + encryptedData.size)
|
||||||
|
System.arraycopy(iv, 0, result, 0, iv.size)
|
||||||
|
System.arraycopy(encryptedData, 0, result, iv.size, encryptedData.size)
|
||||||
|
|
||||||
|
result
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to encrypt channel message: ${e.message}")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a message for a channel
|
||||||
|
* Expects data format: IV + encrypted_data + auth_tag
|
||||||
|
*/
|
||||||
|
fun decryptChannelMessage(encryptedData: ByteArray, channel: String): String {
|
||||||
|
val key = channelKeys[channel]
|
||||||
|
?: throw IllegalStateException("No key available for channel $channel")
|
||||||
|
|
||||||
|
if (encryptedData.size < 16) { // 12 bytes IV + minimum ciphertext
|
||||||
|
throw IllegalArgumentException("Encrypted data too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
|
||||||
|
// Extract IV (first 12 bytes for GCM) and ciphertext
|
||||||
|
val iv = encryptedData.sliceArray(0..11)
|
||||||
|
val ciphertext = encryptedData.sliceArray(12 until encryptedData.size)
|
||||||
|
|
||||||
|
val gcmSpec = GCMParameterSpec(128, iv) // 128-bit authentication tag
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec)
|
||||||
|
|
||||||
|
val decryptedBytes = cipher.doFinal(ciphertext)
|
||||||
|
String(decryptedBytes, Charsets.UTF_8)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to decrypt channel message: ${e.message}")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Key Derivation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive AES key from password using PBKDF2 (same parameters as iOS)
|
||||||
|
*/
|
||||||
|
private fun deriveChannelKey(password: String, channel: String): SecretKeySpec {
|
||||||
|
try {
|
||||||
|
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||||
|
|
||||||
|
// Use channel name as salt (UTF-8 bytes)
|
||||||
|
val salt = channel.toByteArray(Charsets.UTF_8)
|
||||||
|
|
||||||
|
val spec = PBEKeySpec(
|
||||||
|
password.toCharArray(),
|
||||||
|
salt,
|
||||||
|
PBKDF2_ITERATIONS,
|
||||||
|
KEY_LENGTH
|
||||||
|
)
|
||||||
|
|
||||||
|
val secretKey = factory.generateSecret(spec)
|
||||||
|
return SecretKeySpec(secretKey.encoded, "AES")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to derive channel key: ${e.message}")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Key Verification
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate key commitment (SHA-256 hash) for verification
|
||||||
|
* This allows peers to verify they have the same key without revealing it
|
||||||
|
*/
|
||||||
|
fun calculateKeyCommitment(channel: String): String? {
|
||||||
|
val key = channelKeys[channel] ?: return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
val hash = digest.digest(key.encoded)
|
||||||
|
hash.joinToString("") { "%02x".format(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to calculate key commitment: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify key commitment matches our derived key
|
||||||
|
*/
|
||||||
|
fun verifyKeyCommitment(channel: String, commitment: String): Boolean {
|
||||||
|
val ourCommitment = calculateKeyCommitment(channel)
|
||||||
|
return ourCommitment?.lowercase() == commitment.lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Channel Key Sharing
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create channel key packet for sharing via Noise session
|
||||||
|
* Returns encrypted packet that can be sent to other peers
|
||||||
|
*/
|
||||||
|
fun createChannelKeyPacket(password: String, channel: String): ByteArray? {
|
||||||
|
return try {
|
||||||
|
// Create key packet with channel and password
|
||||||
|
val packet = mapOf(
|
||||||
|
"channel" to channel,
|
||||||
|
"password" to password,
|
||||||
|
"timestamp" to System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simple JSON encoding for now (could be replaced with more efficient format)
|
||||||
|
val json = com.google.gson.Gson().toJson(packet)
|
||||||
|
json.toByteArray(Charsets.UTF_8)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to create channel key packet: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process received channel key packet
|
||||||
|
* Returns (channel, password) if successful
|
||||||
|
*/
|
||||||
|
fun processChannelKeyPacket(data: ByteArray): Pair<String, String>? {
|
||||||
|
return try {
|
||||||
|
val json = String(data, Charsets.UTF_8)
|
||||||
|
val packet = com.google.gson.Gson().fromJson(json, Map::class.java) as Map<String, Any>
|
||||||
|
|
||||||
|
val channel = packet["channel"] as? String
|
||||||
|
val password = packet["password"] as? String
|
||||||
|
|
||||||
|
if (channel != null && password != null) {
|
||||||
|
Pair(channel, password)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Invalid channel key packet format")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to process channel key packet: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug and Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get debug information
|
||||||
|
*/
|
||||||
|
fun getDebugInfo(): String = buildString {
|
||||||
|
appendLine("=== Channel Encryption Debug ===")
|
||||||
|
appendLine("Active channels: ${channelKeys.size}")
|
||||||
|
|
||||||
|
channelKeys.keys.forEach { channel ->
|
||||||
|
val hasPassword = channelPasswords.containsKey(channel)
|
||||||
|
val commitment = calculateKeyCommitment(channel)?.take(16)
|
||||||
|
appendLine(" $channel: hasPassword=$hasPassword, commitment=${commitment}...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of channels with keys
|
||||||
|
*/
|
||||||
|
fun getActiveChannels(): Set<String> {
|
||||||
|
return channelKeys.keys.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all channel data
|
||||||
|
*/
|
||||||
|
fun clear() {
|
||||||
|
channelKeys.clear()
|
||||||
|
channelPasswords.clear()
|
||||||
|
Log.d(TAG, "Cleared all channel encryption data")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package com.bitchat.android.noise
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Noise encryption service - 100% compatible with iOS implementation
|
||||||
|
*
|
||||||
|
* This service manages:
|
||||||
|
* - Static identity keys (persistent across sessions)
|
||||||
|
* - Noise session management for each peer
|
||||||
|
* - Channel encryption using password-derived keys
|
||||||
|
* - Peer fingerprint mapping and identity persistence
|
||||||
|
*/
|
||||||
|
class NoiseEncryptionService(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NoiseEncryptionService"
|
||||||
|
|
||||||
|
// Session limits for performance and security
|
||||||
|
private const val REKEY_TIME_LIMIT = 3600000L // 1 hour (same as iOS)
|
||||||
|
private const val REKEY_MESSAGE_LIMIT = 10000L // 10k messages (same as iOS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static identity key (persistent across app restarts) - loaded from secure storage
|
||||||
|
private val staticIdentityPrivateKey: ByteArray
|
||||||
|
private val staticIdentityPublicKey: ByteArray
|
||||||
|
|
||||||
|
// Session management
|
||||||
|
private val sessionManager: NoiseSessionManager
|
||||||
|
|
||||||
|
// Channel encryption for password-protected channels
|
||||||
|
private val channelEncryption = NoiseChannelEncryption()
|
||||||
|
|
||||||
|
// Identity management for peer ID rotation support
|
||||||
|
private val identityStateManager: SecureIdentityStateManager
|
||||||
|
|
||||||
|
// Peer fingerprints mapping (peerID -> fingerprint and fingerprint -> peerID)
|
||||||
|
private val peerFingerprints = ConcurrentHashMap<String, String>() // peerID -> fingerprint
|
||||||
|
private val fingerprintToPeerID = ConcurrentHashMap<String, String>() // fingerprint -> current peerID
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
var onPeerAuthenticated: ((String, String) -> Unit)? = null // (peerID, fingerprint)
|
||||||
|
var onHandshakeRequired: ((String) -> Unit)? = null // peerID needs handshake
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Initialize identity state manager for persistent storage
|
||||||
|
identityStateManager = SecureIdentityStateManager(context)
|
||||||
|
|
||||||
|
// Load or create static identity key (persistent across sessions)
|
||||||
|
val loadedKeyPair = identityStateManager.loadStaticKey()
|
||||||
|
if (loadedKeyPair != null) {
|
||||||
|
staticIdentityPrivateKey = loadedKeyPair.first
|
||||||
|
staticIdentityPublicKey = loadedKeyPair.second
|
||||||
|
Log.d(TAG, "Loaded existing static identity key")
|
||||||
|
} else {
|
||||||
|
// Generate new identity key pair
|
||||||
|
val keyPair = generateKeyPair()
|
||||||
|
staticIdentityPrivateKey = keyPair.first
|
||||||
|
staticIdentityPublicKey = keyPair.second
|
||||||
|
|
||||||
|
// Save to secure storage
|
||||||
|
identityStateManager.saveStaticKey(staticIdentityPrivateKey, staticIdentityPublicKey)
|
||||||
|
Log.d(TAG, "Generated and saved new static identity key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize session manager
|
||||||
|
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey)
|
||||||
|
|
||||||
|
// Set up session callbacks
|
||||||
|
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
|
||||||
|
handleSessionEstablished(peerID, remoteStaticKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Interface
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get our static public key data for sharing (32 bytes)
|
||||||
|
*/
|
||||||
|
fun getStaticPublicKeyData(): ByteArray {
|
||||||
|
return staticIdentityPublicKey.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get our identity fingerprint (SHA-256 hash of static public key)
|
||||||
|
*/
|
||||||
|
fun getIdentityFingerprint(): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
val hash = digest.digest(staticIdentityPublicKey)
|
||||||
|
return hash.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get peer's public key data (if we have a session)
|
||||||
|
*/
|
||||||
|
fun getPeerPublicKeyData(peerID: String): ByteArray? {
|
||||||
|
return sessionManager.getRemoteStaticKey(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear persistent identity (for panic mode)
|
||||||
|
*/
|
||||||
|
fun clearPersistentIdentity() {
|
||||||
|
identityStateManager.clearIdentityData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Handshake Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate a Noise handshake with a peer
|
||||||
|
* Returns the first handshake message to send
|
||||||
|
*/
|
||||||
|
fun initiateHandshake(peerID: String): ByteArray? {
|
||||||
|
return try {
|
||||||
|
sessionManager.initiateHandshake(peerID)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to initiate handshake with $peerID: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an incoming handshake message
|
||||||
|
* Returns response message if needed, null if handshake complete or failed
|
||||||
|
*/
|
||||||
|
fun processHandshakeMessage(data: ByteArray, peerID: String): ByteArray? {
|
||||||
|
return try {
|
||||||
|
sessionManager.handleIncomingHandshake(peerID, data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to process handshake from $peerID: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we have an established session with a peer
|
||||||
|
*/
|
||||||
|
fun hasEstablishedSession(peerID: String): Boolean {
|
||||||
|
return sessionManager.hasEstablishedSession(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data for a specific peer using established Noise session
|
||||||
|
*/
|
||||||
|
fun encrypt(data: ByteArray, peerID: String): ByteArray? {
|
||||||
|
if (!hasEstablishedSession(peerID)) {
|
||||||
|
Log.w(TAG, "No established session with $peerID, handshake required")
|
||||||
|
onHandshakeRequired?.invoke(peerID)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
sessionManager.encrypt(data, peerID)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to encrypt for $peerID: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt data from a specific peer using established Noise session
|
||||||
|
*/
|
||||||
|
fun decrypt(encryptedData: ByteArray, peerID: String): ByteArray? {
|
||||||
|
if (!hasEstablishedSession(peerID)) {
|
||||||
|
Log.w(TAG, "No established session with $peerID")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
sessionManager.decrypt(encryptedData, peerID)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to decrypt from $peerID: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Peer Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fingerprint for a peer (returns null if peer unknown)
|
||||||
|
*/
|
||||||
|
fun getPeerFingerprint(peerID: String): String? {
|
||||||
|
return peerFingerprints[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current peer ID for a fingerprint (returns null if not currently online)
|
||||||
|
*/
|
||||||
|
fun getPeerID(fingerprint: String): String? {
|
||||||
|
return fingerprintToPeerID[fingerprint]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a peer session (called when peer disconnects)
|
||||||
|
*/
|
||||||
|
fun removePeer(peerID: String) {
|
||||||
|
sessionManager.removeSession(peerID)
|
||||||
|
|
||||||
|
// Clean up fingerprint mappings
|
||||||
|
val fingerprint = peerFingerprints.remove(peerID)
|
||||||
|
if (fingerprint != null) {
|
||||||
|
fingerprintToPeerID.remove(fingerprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update peer ID mapping (for peer ID rotation)
|
||||||
|
* This allows favorites/blocking to persist across peer ID changes
|
||||||
|
*/
|
||||||
|
fun updatePeerIDMapping(oldPeerID: String?, newPeerID: String, fingerprint: String) {
|
||||||
|
// Remove old mapping if exists
|
||||||
|
oldPeerID?.let { oldID ->
|
||||||
|
peerFingerprints.remove(oldID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new mapping
|
||||||
|
peerFingerprints[newPeerID] = fingerprint
|
||||||
|
fingerprintToPeerID[fingerprint] = newPeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Channel Encryption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set password for a channel (derives encryption key)
|
||||||
|
*/
|
||||||
|
fun setChannelPassword(password: String, channel: String) {
|
||||||
|
channelEncryption.setChannelPassword(password, channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt message for a password-protected channel
|
||||||
|
*/
|
||||||
|
fun encryptChannelMessage(message: String, channel: String): ByteArray? {
|
||||||
|
return try {
|
||||||
|
channelEncryption.encryptChannelMessage(message, channel)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to encrypt channel message for $channel: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt channel message
|
||||||
|
*/
|
||||||
|
fun decryptChannelMessage(encryptedData: ByteArray, channel: String): String? {
|
||||||
|
return try {
|
||||||
|
channelEncryption.decryptChannelMessage(encryptedData, channel)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to decrypt channel message for $channel: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove channel password (when leaving channel)
|
||||||
|
*/
|
||||||
|
fun removeChannelPassword(channel: String) {
|
||||||
|
channelEncryption.removeChannelPassword(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Maintenance
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sessions that need rekey based on time or message count
|
||||||
|
*/
|
||||||
|
fun getSessionsNeedingRekey(): List<String> {
|
||||||
|
return sessionManager.getSessionsNeedingRekey()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate rekey for a session (replaces old session with new handshake)
|
||||||
|
*/
|
||||||
|
fun initiateRekey(peerID: String): ByteArray? {
|
||||||
|
Log.d(TAG, "Initiating rekey for session with $peerID")
|
||||||
|
|
||||||
|
// Remove old session
|
||||||
|
sessionManager.removeSession(peerID)
|
||||||
|
|
||||||
|
// Start new handshake
|
||||||
|
return initiateHandshake(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new Curve25519 key pair
|
||||||
|
* Returns (privateKey, publicKey) as 32-byte arrays
|
||||||
|
*/
|
||||||
|
private fun generateKeyPair(): Pair<ByteArray, ByteArray> {
|
||||||
|
val random = SecureRandom()
|
||||||
|
val privateKey = ByteArray(32)
|
||||||
|
random.nextBytes(privateKey)
|
||||||
|
|
||||||
|
// Clamp private key for Curve25519
|
||||||
|
privateKey[0] = (privateKey[0].toInt() and 248).toByte()
|
||||||
|
privateKey[31] = (privateKey[31].toInt() and 127).toByte()
|
||||||
|
privateKey[31] = (privateKey[31].toInt() or 64).toByte()
|
||||||
|
|
||||||
|
// For now, we'll compute public key using the Noise library
|
||||||
|
// This will be properly implemented when we integrate the actual Noise library
|
||||||
|
val publicKey = ByteArray(32)
|
||||||
|
random.nextBytes(publicKey) // Temporary - will be replaced with proper curve25519 scalar multiplication
|
||||||
|
|
||||||
|
return Pair(privateKey, publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle session establishment (called when Noise handshake completes)
|
||||||
|
*/
|
||||||
|
private fun handleSessionEstablished(peerID: String, remoteStaticKey: ByteArray) {
|
||||||
|
// Calculate fingerprint from remote static key
|
||||||
|
val fingerprint = calculateFingerprint(remoteStaticKey)
|
||||||
|
|
||||||
|
// Store fingerprint mapping
|
||||||
|
peerFingerprints[peerID] = fingerprint
|
||||||
|
fingerprintToPeerID[fingerprint] = peerID
|
||||||
|
|
||||||
|
Log.d(TAG, "Session established with $peerID, fingerprint: ${fingerprint.take(16)}...")
|
||||||
|
|
||||||
|
// Notify about authentication
|
||||||
|
onPeerAuthenticated?.invoke(peerID, fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate fingerprint from public key (SHA-256 hash)
|
||||||
|
*/
|
||||||
|
private fun calculateFingerprint(publicKey: ByteArray): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
val hash = digest.digest(publicKey)
|
||||||
|
return hash.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean shutdown
|
||||||
|
*/
|
||||||
|
fun shutdown() {
|
||||||
|
sessionManager.shutdown()
|
||||||
|
channelEncryption.clear()
|
||||||
|
peerFingerprints.clear()
|
||||||
|
fingerprintToPeerID.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Noise-specific errors
|
||||||
|
*/
|
||||||
|
sealed class NoiseEncryptionError(message: String) : Exception(message) {
|
||||||
|
object HandshakeRequired : NoiseEncryptionError("Handshake required before encryption")
|
||||||
|
object SessionNotEstablished : NoiseEncryptionError("No established Noise session")
|
||||||
|
object InvalidMessage : NoiseEncryptionError("Invalid message format")
|
||||||
|
class HandshakeFailed(cause: Throwable) : NoiseEncryptionError("Handshake failed: ${cause.message}")
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package com.bitchat.android.noise
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual Noise session for a specific peer - 100% compatible with iOS implementation
|
||||||
|
*
|
||||||
|
* Represents a single Noise XX protocol session with the following states:
|
||||||
|
* - UNINITIALIZED: Session created but handshake not started
|
||||||
|
* - HANDSHAKING: Handshake in progress (messages 1, 2, or 3)
|
||||||
|
* - ESTABLISHED: Handshake complete, ready for transport encryption
|
||||||
|
* - FAILED: Session failed and needs to be recreated
|
||||||
|
*/
|
||||||
|
class NoiseSession(
|
||||||
|
private val peerID: String,
|
||||||
|
private val isInitiator: Boolean,
|
||||||
|
private val localStaticPrivateKey: ByteArray,
|
||||||
|
private val localStaticPublicKey: ByteArray
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NoiseSession"
|
||||||
|
|
||||||
|
// Rekey thresholds (same as iOS)
|
||||||
|
private const val REKEY_TIME_LIMIT = 3600000L // 1 hour
|
||||||
|
private const val REKEY_MESSAGE_LIMIT = 10000L // 10k messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session state
|
||||||
|
private var state: NoiseSessionState = NoiseSessionState.Uninitialized
|
||||||
|
private val creationTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// Handshake state (will be replaced with actual Noise implementation)
|
||||||
|
private var handshakeStep = 0
|
||||||
|
private var handshakeMessages = mutableListOf<ByteArray>()
|
||||||
|
|
||||||
|
// Transport state (after handshake completion)
|
||||||
|
private var sendKey: ByteArray? = null
|
||||||
|
private var receiveKey: ByteArray? = null
|
||||||
|
private var sendNonce = 0L
|
||||||
|
private var receiveNonce = 0L
|
||||||
|
|
||||||
|
// Remote peer information
|
||||||
|
private var remoteStaticPublicKey: ByteArray? = null
|
||||||
|
private var handshakeHash: ByteArray? = null
|
||||||
|
|
||||||
|
// Message counting for rekey
|
||||||
|
private var messagesSent = 0L
|
||||||
|
private var messagesReceived = 0L
|
||||||
|
|
||||||
|
// MARK: - Session State
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session states matching iOS implementation
|
||||||
|
*/
|
||||||
|
sealed class NoiseSessionState {
|
||||||
|
object Uninitialized : NoiseSessionState()
|
||||||
|
object Handshaking : NoiseSessionState()
|
||||||
|
object Established : NoiseSessionState()
|
||||||
|
data class Failed(val error: Throwable) : NoiseSessionState()
|
||||||
|
|
||||||
|
override fun toString(): String = when (this) {
|
||||||
|
is Uninitialized -> "uninitialized"
|
||||||
|
is Handshaking -> "handshaking"
|
||||||
|
is Established -> "established"
|
||||||
|
is Failed -> "failed: ${error.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getState(): NoiseSessionState = state
|
||||||
|
fun isEstablished(): Boolean = state is NoiseSessionState.Established
|
||||||
|
fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking
|
||||||
|
fun getCreationTime(): Long = creationTime
|
||||||
|
|
||||||
|
// MARK: - Handshake Implementation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start handshake (initiator only)
|
||||||
|
* Returns the first handshake message (32 bytes - ephemeral public key)
|
||||||
|
*/
|
||||||
|
fun startHandshake(): ByteArray {
|
||||||
|
if (!isInitiator) {
|
||||||
|
throw IllegalStateException("Only initiator can start handshake")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state != NoiseSessionState.Uninitialized) {
|
||||||
|
throw IllegalStateException("Handshake already started")
|
||||||
|
}
|
||||||
|
|
||||||
|
state = NoiseSessionState.Handshaking
|
||||||
|
handshakeStep = 1
|
||||||
|
|
||||||
|
// TODO: Replace with actual Noise implementation
|
||||||
|
// For now, generate a mock 32-byte ephemeral key message
|
||||||
|
val ephemeralKey = ByteArray(32)
|
||||||
|
SecureRandom().nextBytes(ephemeralKey)
|
||||||
|
|
||||||
|
handshakeMessages.add(ephemeralKey)
|
||||||
|
|
||||||
|
Log.d(TAG, "Started handshake with $peerID (step 1)")
|
||||||
|
return ephemeralKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process incoming handshake message
|
||||||
|
* Returns response message if needed, null if handshake complete
|
||||||
|
*/
|
||||||
|
fun processHandshakeMessage(message: ByteArray): ByteArray? {
|
||||||
|
Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes)")
|
||||||
|
|
||||||
|
// Initialize if we're a responder receiving the first message
|
||||||
|
if (state == NoiseSessionState.Uninitialized && !isInitiator) {
|
||||||
|
state = NoiseSessionState.Handshaking
|
||||||
|
handshakeStep = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state != NoiseSessionState.Handshaking) {
|
||||||
|
throw IllegalStateException("Invalid state for handshake message: $state")
|
||||||
|
}
|
||||||
|
|
||||||
|
handshakeMessages.add(message)
|
||||||
|
|
||||||
|
// TODO: Replace with actual Noise XX implementation
|
||||||
|
// This is a mock implementation that follows the XX pattern steps
|
||||||
|
|
||||||
|
return when (handshakeStep) {
|
||||||
|
1 -> {
|
||||||
|
// Received first message (e)
|
||||||
|
if (isInitiator) {
|
||||||
|
throw IllegalStateException("Initiator should not receive first message")
|
||||||
|
}
|
||||||
|
|
||||||
|
handshakeStep = 2
|
||||||
|
|
||||||
|
// Generate response message (e, ee, s, es) - mock implementation
|
||||||
|
val responseMessage = ByteArray(96) // 32 (e) + 48 (encrypted s) + 16 (tag)
|
||||||
|
SecureRandom().nextBytes(responseMessage)
|
||||||
|
|
||||||
|
Log.d(TAG, "Sending handshake response to $peerID (step 2)")
|
||||||
|
responseMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
2 -> {
|
||||||
|
// Received second message (e, ee, s, es)
|
||||||
|
if (!isInitiator) {
|
||||||
|
throw IllegalStateException("Responder should not receive second message")
|
||||||
|
}
|
||||||
|
|
||||||
|
handshakeStep = 3
|
||||||
|
|
||||||
|
// Generate final message (s, se) - mock implementation
|
||||||
|
val finalMessage = ByteArray(48) // 48 bytes (encrypted s) + 16 (tag)
|
||||||
|
SecureRandom().nextBytes(finalMessage)
|
||||||
|
|
||||||
|
Log.d(TAG, "Sending final handshake message to $peerID (step 3)")
|
||||||
|
finalMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
3 -> {
|
||||||
|
// Received final message (s, se)
|
||||||
|
if (isInitiator) {
|
||||||
|
throw IllegalStateException("Initiator should not receive third message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete handshake
|
||||||
|
completeHandshake()
|
||||||
|
null // No response needed
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
throw IllegalStateException("Invalid handshake step: $handshakeStep")
|
||||||
|
}
|
||||||
|
}.also {
|
||||||
|
// Check if we (as initiator) completed handshake after sending final message
|
||||||
|
if (isInitiator && handshakeStep == 3) {
|
||||||
|
completeHandshake()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete handshake and derive transport keys
|
||||||
|
*/
|
||||||
|
private fun completeHandshake() {
|
||||||
|
Log.d(TAG, "Completing handshake with $peerID")
|
||||||
|
|
||||||
|
// TODO: Replace with actual Noise session split
|
||||||
|
// For now, generate mock transport keys
|
||||||
|
sendKey = ByteArray(32)
|
||||||
|
receiveKey = ByteArray(32)
|
||||||
|
remoteStaticPublicKey = ByteArray(32)
|
||||||
|
handshakeHash = ByteArray(32)
|
||||||
|
|
||||||
|
SecureRandom().apply {
|
||||||
|
nextBytes(sendKey!!)
|
||||||
|
nextBytes(receiveKey!!)
|
||||||
|
nextBytes(remoteStaticPublicKey!!)
|
||||||
|
nextBytes(handshakeHash!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset nonces for transport mode
|
||||||
|
sendNonce = 0
|
||||||
|
receiveNonce = 0
|
||||||
|
|
||||||
|
state = NoiseSessionState.Established
|
||||||
|
Log.d(TAG, "Handshake completed with $peerID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport Encryption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data in transport mode
|
||||||
|
*/
|
||||||
|
fun encrypt(data: ByteArray): ByteArray {
|
||||||
|
if (!isEstablished()) {
|
||||||
|
throw IllegalStateException("Session not established")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Replace with actual ChaCha20-Poly1305 encryption
|
||||||
|
// For now, return mock encrypted data
|
||||||
|
val encryptedData = ByteArray(data.size + 16) // Add 16-byte MAC tag
|
||||||
|
System.arraycopy(data, 0, encryptedData, 0, data.size)
|
||||||
|
|
||||||
|
// Fill MAC tag area with mock data
|
||||||
|
SecureRandom().nextBytes(encryptedData.sliceArray(data.size until encryptedData.size))
|
||||||
|
|
||||||
|
messagesSent++
|
||||||
|
sendNonce++
|
||||||
|
|
||||||
|
return encryptedData
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt data in transport mode
|
||||||
|
*/
|
||||||
|
fun decrypt(encryptedData: ByteArray): ByteArray {
|
||||||
|
if (!isEstablished()) {
|
||||||
|
throw IllegalStateException("Session not established")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encryptedData.size < 16) {
|
||||||
|
throw IllegalArgumentException("Encrypted data too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Replace with actual ChaCha20-Poly1305 decryption
|
||||||
|
// For now, return mock decrypted data (remove 16-byte MAC tag)
|
||||||
|
val decryptedData = ByteArray(encryptedData.size - 16)
|
||||||
|
System.arraycopy(encryptedData, 0, decryptedData, 0, decryptedData.size)
|
||||||
|
|
||||||
|
messagesReceived++
|
||||||
|
receiveNonce++
|
||||||
|
|
||||||
|
return decryptedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Information
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remote static public key (available after handshake completion)
|
||||||
|
*/
|
||||||
|
fun getRemoteStaticPublicKey(): ByteArray? {
|
||||||
|
return remoteStaticPublicKey?.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get handshake hash for channel binding
|
||||||
|
*/
|
||||||
|
fun getHandshakeHash(): ByteArray? {
|
||||||
|
return handshakeHash?.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if session needs rekeying
|
||||||
|
*/
|
||||||
|
fun needsRekey(): Boolean {
|
||||||
|
if (!isEstablished()) return false
|
||||||
|
|
||||||
|
val timeLimit = System.currentTimeMillis() - creationTime > REKEY_TIME_LIMIT
|
||||||
|
val messageLimit = (messagesSent + messagesReceived) > REKEY_MESSAGE_LIMIT
|
||||||
|
|
||||||
|
return timeLimit || messageLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session statistics
|
||||||
|
*/
|
||||||
|
fun getSessionStats(): String = buildString {
|
||||||
|
appendLine("Session with $peerID:")
|
||||||
|
appendLine(" State: $state")
|
||||||
|
appendLine(" Role: ${if (isInitiator) "initiator" else "responder"}")
|
||||||
|
appendLine(" Handshake step: $handshakeStep")
|
||||||
|
appendLine(" Messages sent: $messagesSent")
|
||||||
|
appendLine(" Messages received: $messagesReceived")
|
||||||
|
appendLine(" Session age: ${(System.currentTimeMillis() - creationTime) / 1000}s")
|
||||||
|
appendLine(" Needs rekey: ${needsRekey()}")
|
||||||
|
appendLine(" Has remote key: ${remoteStaticPublicKey != null}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset session state
|
||||||
|
*/
|
||||||
|
fun reset() {
|
||||||
|
state = NoiseSessionState.Uninitialized
|
||||||
|
handshakeStep = 0
|
||||||
|
handshakeMessages.clear()
|
||||||
|
sendKey = null
|
||||||
|
receiveKey = null
|
||||||
|
sendNonce = 0
|
||||||
|
receiveNonce = 0
|
||||||
|
remoteStaticPublicKey = null
|
||||||
|
handshakeHash = null
|
||||||
|
messagesSent = 0
|
||||||
|
messagesReceived = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up session resources
|
||||||
|
*/
|
||||||
|
fun destroy() {
|
||||||
|
try {
|
||||||
|
// Clear sensitive keys
|
||||||
|
sendKey?.fill(0)
|
||||||
|
receiveKey?.fill(0)
|
||||||
|
remoteStaticPublicKey?.fill(0)
|
||||||
|
handshakeHash?.fill(0)
|
||||||
|
|
||||||
|
// Clear handshake messages
|
||||||
|
handshakeMessages.forEach { it.fill(0) }
|
||||||
|
handshakeMessages.clear()
|
||||||
|
|
||||||
|
state = NoiseSessionState.Failed(Exception("Session destroyed"))
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error during session cleanup: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-specific errors
|
||||||
|
*/
|
||||||
|
sealed class SessionError(message: String, cause: Throwable? = null) : Exception(message, cause) {
|
||||||
|
object InvalidState : SessionError("Session in invalid state")
|
||||||
|
object NotEstablished : SessionError("Session not established")
|
||||||
|
object HandshakeFailed : SessionError("Handshake failed")
|
||||||
|
object EncryptionFailed : SessionError("Encryption failed")
|
||||||
|
object DecryptionFailed : SessionError("Decryption failed")
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
package com.bitchat.android.noise
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||||
|
import kotlin.concurrent.read
|
||||||
|
import kotlin.concurrent.write
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread-safe manager for Noise sessions - 100% compatible with iOS implementation
|
||||||
|
*
|
||||||
|
* Manages the lifecycle of Noise sessions for each peer, including:
|
||||||
|
* - Handshake initiation and processing
|
||||||
|
* - Session establishment and transport encryption
|
||||||
|
* - Session cleanup and rekey support
|
||||||
|
* - Thread-safe operations for concurrent access
|
||||||
|
*/
|
||||||
|
class NoiseSessionManager(
|
||||||
|
private val localStaticPrivateKey: ByteArray,
|
||||||
|
private val localStaticPublicKey: ByteArray
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NoiseSessionManager"
|
||||||
|
private const val MAX_SESSIONS = 50 // Prevent memory exhaustion
|
||||||
|
private const val SESSION_CLEANUP_INTERVAL = 300000L // 5 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread-safe session storage
|
||||||
|
private val sessions = ConcurrentHashMap<String, NoiseSession>()
|
||||||
|
private val sessionLock = ReentrantReadWriteLock()
|
||||||
|
|
||||||
|
// Pending handshakes to handle handshake storms and prevent duplicates
|
||||||
|
private val pendingHandshakes = ConcurrentHashMap<String, HandshakeInfo>()
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
var onSessionEstablished: ((String, ByteArray) -> Unit)? = null // (peerID, remoteStaticKey)
|
||||||
|
var onSessionFailed: ((String, Throwable) -> Unit)? = null // (peerID, error)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about ongoing handshakes
|
||||||
|
*/
|
||||||
|
data class HandshakeInfo(
|
||||||
|
val startTime: Long,
|
||||||
|
val isInitiator: Boolean,
|
||||||
|
var step: Int = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - Session Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session for a peer with specified role
|
||||||
|
*/
|
||||||
|
private fun createSession(peerID: String, isInitiator: Boolean): NoiseSession {
|
||||||
|
return sessionLock.write {
|
||||||
|
val session = NoiseSession(
|
||||||
|
peerID = peerID,
|
||||||
|
isInitiator = isInitiator,
|
||||||
|
localStaticPrivateKey = localStaticPrivateKey,
|
||||||
|
localStaticPublicKey = localStaticPublicKey
|
||||||
|
)
|
||||||
|
sessions[peerID] = session
|
||||||
|
session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get existing session for a peer (thread-safe)
|
||||||
|
*/
|
||||||
|
fun getSession(peerID: String): NoiseSession? {
|
||||||
|
return sessionLock.read {
|
||||||
|
sessions[peerID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove session for a peer (thread-safe)
|
||||||
|
*/
|
||||||
|
fun removeSession(peerID: String) {
|
||||||
|
sessionLock.write {
|
||||||
|
sessions[peerID]?.destroy()
|
||||||
|
sessions.remove(peerID)
|
||||||
|
pendingHandshakes.remove(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all established sessions
|
||||||
|
*/
|
||||||
|
fun getEstablishedSessions(): Map<String, NoiseSession> {
|
||||||
|
return sessionLock.read {
|
||||||
|
sessions.filter { it.value.isEstablished() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Handshake Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate handshake as the initiator
|
||||||
|
* Implements tie-breaker mechanism to prevent handshake storms
|
||||||
|
*/
|
||||||
|
fun initiateHandshake(peerID: String): ByteArray {
|
||||||
|
return sessionLock.write {
|
||||||
|
// Check if we already have an established session
|
||||||
|
val existingSession = sessions[peerID]
|
||||||
|
if (existingSession?.isEstablished() == true) {
|
||||||
|
throw IllegalStateException("Session already established with $peerID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply tie-breaker mechanism (same as iOS implementation)
|
||||||
|
val shouldInitiate = resolveTieBreaker(peerID)
|
||||||
|
if (!shouldInitiate) {
|
||||||
|
Log.d(TAG, "Tie-breaker: Waiting for $peerID to initiate handshake")
|
||||||
|
throw IllegalStateException("Tie-breaker: Should not initiate with $peerID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing non-established session
|
||||||
|
if (existingSession != null && !existingSession.isEstablished()) {
|
||||||
|
existingSession.destroy()
|
||||||
|
sessions.remove(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new session as initiator
|
||||||
|
val session = createSession(peerID, isInitiator = true)
|
||||||
|
|
||||||
|
// Mark as pending handshake
|
||||||
|
pendingHandshakes[peerID] = HandshakeInfo(
|
||||||
|
startTime = System.currentTimeMillis(),
|
||||||
|
isInitiator = true,
|
||||||
|
step = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val handshakeData = session.startHandshake()
|
||||||
|
Log.d(TAG, "Started handshake with $peerID as initiator")
|
||||||
|
handshakeData
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Clean up failed session
|
||||||
|
sessions.remove(peerID)
|
||||||
|
pendingHandshakes.remove(peerID)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming handshake message
|
||||||
|
* Supports both initiating and responding to handshakes
|
||||||
|
*/
|
||||||
|
fun handleIncomingHandshake(peerID: String, message: ByteArray): ByteArray? {
|
||||||
|
return sessionLock.write {
|
||||||
|
var shouldCreateNew = false
|
||||||
|
var existingSession: NoiseSession? = null
|
||||||
|
|
||||||
|
val existing = sessions[peerID]
|
||||||
|
if (existing == null) {
|
||||||
|
shouldCreateNew = true
|
||||||
|
} else {
|
||||||
|
when {
|
||||||
|
existing.isEstablished() -> {
|
||||||
|
// If this is a handshake initiation (first message), help complete their handshake
|
||||||
|
if (isFirstHandshakeMessage(message)) {
|
||||||
|
Log.d(TAG, "Received new handshake from established peer $peerID, resetting")
|
||||||
|
existing.destroy()
|
||||||
|
sessions.remove(peerID)
|
||||||
|
shouldCreateNew = true
|
||||||
|
} else {
|
||||||
|
// Ignore other handshake messages if already established
|
||||||
|
Log.d(TAG, "Ignoring handshake message from established peer $peerID")
|
||||||
|
return@write null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existing.isHandshaking() -> {
|
||||||
|
// Continue existing handshake
|
||||||
|
if (isFirstHandshakeMessage(message)) {
|
||||||
|
// Peer restarted handshake, reset and start fresh
|
||||||
|
existing.destroy()
|
||||||
|
sessions.remove(peerID)
|
||||||
|
shouldCreateNew = true
|
||||||
|
} else {
|
||||||
|
existingSession = existing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Session in invalid state, recreate
|
||||||
|
existing.destroy()
|
||||||
|
sessions.remove(peerID)
|
||||||
|
shouldCreateNew = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create session
|
||||||
|
val session: NoiseSession = if (shouldCreateNew) {
|
||||||
|
val newSession = createSession(peerID, isInitiator = false)
|
||||||
|
pendingHandshakes[peerID] = HandshakeInfo(
|
||||||
|
startTime = System.currentTimeMillis(),
|
||||||
|
isInitiator = false,
|
||||||
|
step = 1
|
||||||
|
)
|
||||||
|
newSession
|
||||||
|
} else {
|
||||||
|
existingSession!!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process handshake message
|
||||||
|
try {
|
||||||
|
val response = session.processHandshakeMessage(message)
|
||||||
|
|
||||||
|
// Update handshake step
|
||||||
|
pendingHandshakes[peerID]?.let { it.step += 1 }
|
||||||
|
|
||||||
|
// Check if session is established
|
||||||
|
if (session.isEstablished()) {
|
||||||
|
pendingHandshakes.remove(peerID)
|
||||||
|
|
||||||
|
// Get remote static key and notify
|
||||||
|
val remoteStaticKey = session.getRemoteStaticPublicKey()
|
||||||
|
if (remoteStaticKey != null) {
|
||||||
|
Log.d(TAG, "Handshake completed with $peerID")
|
||||||
|
onSessionEstablished?.invoke(peerID, remoteStaticKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Clean up failed session
|
||||||
|
sessions.remove(peerID)
|
||||||
|
pendingHandshakes.remove(peerID)
|
||||||
|
|
||||||
|
Log.e(TAG, "Handshake failed with $peerID: ${e.message}")
|
||||||
|
onSessionFailed?.invoke(peerID, e)
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data for a specific peer using established session
|
||||||
|
*/
|
||||||
|
fun encrypt(data: ByteArray, peerID: String): ByteArray {
|
||||||
|
val session = getSession(peerID)
|
||||||
|
?: throw IllegalStateException("No session found for $peerID")
|
||||||
|
|
||||||
|
if (!session.isEstablished()) {
|
||||||
|
throw IllegalStateException("Session not established with $peerID")
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.encrypt(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt data from a specific peer using established session
|
||||||
|
*/
|
||||||
|
fun decrypt(encryptedData: ByteArray, peerID: String): ByteArray {
|
||||||
|
val session = getSession(peerID)
|
||||||
|
?: throw IllegalStateException("No session found for $peerID")
|
||||||
|
|
||||||
|
if (!session.isEstablished()) {
|
||||||
|
throw IllegalStateException("Session not established with $peerID")
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.decrypt(encryptedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Information
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if session is established with peer
|
||||||
|
*/
|
||||||
|
fun hasEstablishedSession(peerID: String): Boolean {
|
||||||
|
return sessionLock.read {
|
||||||
|
sessions[peerID]?.isEstablished() ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remote static public key for a peer (if session established)
|
||||||
|
*/
|
||||||
|
fun getRemoteStaticKey(peerID: String): ByteArray? {
|
||||||
|
return getSession(peerID)?.getRemoteStaticPublicKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get handshake hash for channel binding (if session established)
|
||||||
|
*/
|
||||||
|
fun getHandshakeHash(peerID: String): ByteArray? {
|
||||||
|
return getSession(peerID)?.getHandshakeHash()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Rekey Support
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sessions that need rekeying based on time or message count
|
||||||
|
*/
|
||||||
|
fun getSessionsNeedingRekey(): List<String> {
|
||||||
|
return sessionLock.read {
|
||||||
|
sessions.entries
|
||||||
|
.filter { (_, session) ->
|
||||||
|
session.isEstablished() && session.needsRekey()
|
||||||
|
}
|
||||||
|
.map { it.key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Cleanup
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up stale sessions and pending handshakes
|
||||||
|
*/
|
||||||
|
fun cleanupStaleSessions() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val toRemove = mutableListOf<String>()
|
||||||
|
|
||||||
|
sessionLock.write {
|
||||||
|
// Clean up old pending handshakes (30 second timeout)
|
||||||
|
pendingHandshakes.entries.removeAll { (peerID, info) ->
|
||||||
|
if (now - info.startTime > 30000) {
|
||||||
|
toRemove.add(peerID)
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove corresponding failed sessions
|
||||||
|
toRemove.forEach { peerID ->
|
||||||
|
sessions[peerID]?.destroy()
|
||||||
|
sessions.remove(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce session limits (remove oldest sessions if too many)
|
||||||
|
if (sessions.size > MAX_SESSIONS) {
|
||||||
|
val oldestSessions = sessions.entries
|
||||||
|
.filter { !it.value.isEstablished() } // Remove non-established first
|
||||||
|
.sortedBy { it.value.getCreationTime() }
|
||||||
|
.take(sessions.size - MAX_SESSIONS)
|
||||||
|
|
||||||
|
oldestSessions.forEach { (peerID, session) ->
|
||||||
|
session.destroy()
|
||||||
|
sessions.remove(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toRemove.isNotEmpty()) {
|
||||||
|
Log.d(TAG, "Cleaned up ${toRemove.size} stale sessions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tie-breaker mechanism to prevent handshake storms
|
||||||
|
* Same logic as iOS implementation
|
||||||
|
*/
|
||||||
|
private fun resolveTieBreaker(peerID: String): Boolean {
|
||||||
|
// Convert peer IDs to comparable format and compare
|
||||||
|
// The peer with lexicographically smaller ID should initiate
|
||||||
|
val myIDString = localStaticPublicKey.joinToString("") { "%02x".format(it) }
|
||||||
|
val peerIDString = peerID.padEnd(myIDString.length, '0')
|
||||||
|
|
||||||
|
return myIDString < peerIDString
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if message is the first handshake message (32 bytes for XX pattern)
|
||||||
|
*/
|
||||||
|
private fun isFirstHandshakeMessage(message: ByteArray): Boolean {
|
||||||
|
return message.size == 32 // First message in XX pattern is just the ephemeral key
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get debug information about session manager state
|
||||||
|
*/
|
||||||
|
fun getDebugInfo(): String = buildString {
|
||||||
|
sessionLock.read {
|
||||||
|
appendLine("=== Noise Session Manager Debug ===")
|
||||||
|
appendLine("Active sessions: ${sessions.size}")
|
||||||
|
appendLine("Pending handshakes: ${pendingHandshakes.size}")
|
||||||
|
appendLine("")
|
||||||
|
|
||||||
|
if (sessions.isNotEmpty()) {
|
||||||
|
appendLine("Sessions:")
|
||||||
|
sessions.forEach { (peerID, session) ->
|
||||||
|
appendLine(" $peerID: ${session.getState()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingHandshakes.isNotEmpty()) {
|
||||||
|
appendLine("Pending handshakes:")
|
||||||
|
pendingHandshakes.forEach { (peerID, info) ->
|
||||||
|
appendLine(" $peerID: step ${info.step}, ${if (info.isInitiator) "initiator" else "responder"}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown manager and clean up all sessions
|
||||||
|
*/
|
||||||
|
fun shutdown() {
|
||||||
|
sessionLock.write {
|
||||||
|
sessions.values.forEach { it.destroy() }
|
||||||
|
sessions.clear()
|
||||||
|
pendingHandshakes.clear()
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Noise session manager shut down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-related errors
|
||||||
|
*/
|
||||||
|
sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exception(message, cause) {
|
||||||
|
object SessionNotFound : NoiseSessionError("Session not found")
|
||||||
|
object SessionNotEstablished : NoiseSessionError("Session not established")
|
||||||
|
object InvalidState : NoiseSessionError("Session in invalid state")
|
||||||
|
object HandshakeFailed : NoiseSessionError("Handshake failed")
|
||||||
|
object AlreadyEstablished : NoiseSessionError("Session already established")
|
||||||
|
}
|
||||||
@@ -7,21 +7,35 @@ import java.nio.ByteOrder
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message types - exact same as iOS version
|
* Message types - exact same as iOS version with Noise Protocol support
|
||||||
*/
|
*/
|
||||||
enum class MessageType(val value: UByte) {
|
enum class MessageType(val value: UByte) {
|
||||||
ANNOUNCE(0x01u),
|
ANNOUNCE(0x01u),
|
||||||
KEY_EXCHANGE(0x02u),
|
// 0x02 was legacy keyExchange - removed
|
||||||
LEAVE(0x03u),
|
LEAVE(0x03u),
|
||||||
MESSAGE(0x04u),
|
MESSAGE(0x04u), // All user messages (private and broadcast)
|
||||||
FRAGMENT_START(0x05u),
|
FRAGMENT_START(0x05u),
|
||||||
FRAGMENT_CONTINUE(0x06u),
|
FRAGMENT_CONTINUE(0x06u),
|
||||||
FRAGMENT_END(0x07u),
|
FRAGMENT_END(0x07u),
|
||||||
CHANNEL_ANNOUNCE(0x08u),
|
CHANNEL_ANNOUNCE(0x08u), // Announce password-protected channel status
|
||||||
CHANNEL_RETENTION(0x09u),
|
CHANNEL_RETENTION(0x09u), // Announce channel retention status
|
||||||
DELIVERY_ACK(0x0Au),
|
DELIVERY_ACK(0x0Au), // Acknowledge message received
|
||||||
DELIVERY_STATUS_REQUEST(0x0Bu),
|
DELIVERY_STATUS_REQUEST(0x0Bu), // Request delivery status update
|
||||||
READ_RECEIPT(0x0Cu);
|
READ_RECEIPT(0x0Cu), // Message has been read/viewed
|
||||||
|
|
||||||
|
// Noise Protocol messages - exact same as iOS
|
||||||
|
NOISE_HANDSHAKE_INIT(0x10u), // Noise handshake initiation
|
||||||
|
NOISE_HANDSHAKE_RESP(0x11u), // Noise handshake response
|
||||||
|
NOISE_ENCRYPTED(0x12u), // Noise encrypted transport message
|
||||||
|
NOISE_IDENTITY_ANNOUNCE(0x13u), // Announce static public key for discovery
|
||||||
|
CHANNEL_KEY_VERIFY_REQUEST(0x14u), // Request key verification for a channel
|
||||||
|
CHANNEL_KEY_VERIFY_RESPONSE(0x15u), // Response to key verification request
|
||||||
|
CHANNEL_PASSWORD_UPDATE(0x16u), // Distribute new password to channel members
|
||||||
|
CHANNEL_METADATA(0x17u), // Announce channel creator and metadata
|
||||||
|
|
||||||
|
// Protocol version negotiation
|
||||||
|
VERSION_HELLO(0x20u), // Initial version announcement
|
||||||
|
VERSION_ACK(0x21u); // Version acknowledgment
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromValue(value: UByte): MessageType? {
|
fun fromValue(value: UByte): MessageType? {
|
||||||
|
|||||||
Reference in New Issue
Block a user