Implement Noise XX Handshake Protocol for Direct Messages (#180)

* noise

* works?

* noise

* temporary

* better

* wip: use subnet

* better

* barely working

* werk

* subnet

* fix peer ID

* 8 byte peer ID

* wip noise

* wip fixes for noise

* std lib for noise

* noise handshake one step further

* buffers

* use fork

* fix imports

* simplify counter

* remove trash

* hashing

* no prologue

* nice

* wip, use noise encryption

* peer ID hex

* simplify session manager

* heavy logging

* use singleton

* Fix Noise session race condition with elegant per-peer actor serialization

- Use Kotlin coroutine actors for per-peer packet processing
- Each peer gets dedicated actor that processes packets sequentially
- Eliminates race conditions in session management without complex locking
- Single surgical change in PacketProcessor - minimal, maintainable
- Leverages Kotlin's native concurrency primitives

* decrypt correctly

* iniator works now

* clean code and fix signature to null

* better

* no signature in private message

* small fixes

* refactor ack

* refactor but untested

* messages working

* wip ack

* wip fix ack

* more logging

* pending tracker

* keep pending connections on errors

* less logging

* refactor model

* refactor frombinarydata

* idendityannouncement refactor and update to new binary protocol

* fix keys

* refix keys

* dms work

* revert to mainnet

* do not change bluetooth adapter name

* keep code but uncomment

* clean up comments

* cleanup comments
This commit is contained in:
callebtc
2025-07-24 12:01:46 +02:00
committed by GitHub
parent 9e9231353b
commit c3c395832c
58 changed files with 15209 additions and 517 deletions
@@ -1,321 +1,317 @@
package com.bitchat.android.crypto
import android.content.Context
import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import org.bouncycastle.crypto.agreement.X25519Agreement
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
import org.bouncycastle.crypto.generators.X25519KeyPairGenerator
import org.bouncycastle.crypto.params.*
import org.bouncycastle.crypto.signers.Ed25519Signer
import org.bouncycastle.crypto.util.PrivateKeyFactory
import org.bouncycastle.crypto.util.PublicKeyFactory
import org.bouncycastle.jcajce.provider.digest.SHA256
import android.util.Log
import com.bitchat.android.noise.NoiseEncryptionService
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.experimental.and
import java.util.concurrent.ConcurrentHashMap
/**
* Encryption service that's 100% compatible with iOS version
* Uses the same cryptographic algorithms and key derivation
* Encryption service that now uses NoiseEncryptionService internally
* Maintains the same public API for backward compatibility
*
* This is the main interface for all encryption/decryption operations in bitchat.
* It now uses the Noise protocol for secure transport encryption with proper session management.
*/
class EncryptionService(private val context: Context) {
// Key agreement keys for encryption
private val privateKey: X25519PrivateKeyParameters
private val publicKey: X25519PublicKeyParameters
companion object {
private const val TAG = "EncryptionService"
}
// Signing keys for authentication
private val signingPrivateKey: Ed25519PrivateKeyParameters
private val signingPublicKey: Ed25519PublicKeyParameters
// Core Noise encryption service
private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context)
// Persistent identity for favorites (separate from ephemeral keys)
private lateinit var identityKey: Ed25519PrivateKeyParameters
private lateinit var identityPublicKey: Ed25519PublicKeyParameters
// Session tracking for established connections
private val establishedSessions = ConcurrentHashMap<String, String>() // peerID -> fingerprint
// Storage for peer keys
private val peerPublicKeys = mutableMapOf<String, X25519PublicKeyParameters>()
private val peerSigningKeys = mutableMapOf<String, Ed25519PublicKeyParameters>()
private val peerIdentityKeys = mutableMapOf<String, Ed25519PublicKeyParameters>()
private val sharedSecrets = mutableMapOf<String, ByteArray>()
private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
private val secureRandom = SecureRandom()
// Callbacks for UI state updates
var onSessionEstablished: ((String) -> Unit)? = null // peerID
var onSessionLost: ((String) -> Unit)? = null // peerID
var onHandshakeRequired: ((String) -> Unit)? = null // peerID
init {
// Generate ephemeral key pairs for this session
val x25519Generator = X25519KeyPairGenerator()
x25519Generator.init(X25519KeyGenerationParameters(secureRandom))
val x25519KeyPair = x25519Generator.generateKeyPair()
privateKey = x25519KeyPair.private as X25519PrivateKeyParameters
publicKey = x25519KeyPair.public as X25519PublicKeyParameters
// Set up NoiseEncryptionService callbacks
noiseService.onPeerAuthenticated = { peerID, fingerprint ->
Log.d(TAG, "✅ Noise session established with $peerID, fingerprint: ${fingerprint.take(16)}...")
establishedSessions[peerID] = fingerprint
onSessionEstablished?.invoke(peerID)
}
val ed25519Generator = Ed25519KeyPairGenerator()
ed25519Generator.init(Ed25519KeyGenerationParameters(secureRandom))
val ed25519KeyPair = ed25519Generator.generateKeyPair()
signingPrivateKey = ed25519KeyPair.private as Ed25519PrivateKeyParameters
signingPublicKey = ed25519KeyPair.public as Ed25519PublicKeyParameters
// Load or create persistent identity key
val identityKeyBytes = prefs.getString("identity_key", null)
if (identityKeyBytes != null) {
try {
val keyBytes = android.util.Base64.decode(identityKeyBytes, android.util.Base64.DEFAULT)
identityKey = Ed25519PrivateKeyParameters(keyBytes, 0)
} catch (e: Exception) {
// Create new identity key if loading fails
val newIdentityKeyPair = ed25519Generator.generateKeyPair()
identityKey = newIdentityKeyPair.private as Ed25519PrivateKeyParameters
saveIdentityKey()
}
} else {
// First run - create and save identity key
val newIdentityKeyPair = ed25519Generator.generateKeyPair()
identityKey = newIdentityKeyPair.private as Ed25519PrivateKeyParameters
saveIdentityKey()
noiseService.onHandshakeRequired = { peerID ->
Log.d(TAG, "🤝 Handshake required for $peerID")
onHandshakeRequired?.invoke(peerID)
}
identityPublicKey = Ed25519PublicKeyParameters(identityKey.encoded, 0)
}
private fun saveIdentityKey() {
val keyBytes = android.util.Base64.encodeToString(identityKey.encoded, android.util.Base64.DEFAULT)
prefs.edit().putString("identity_key", keyBytes).apply()
}
// MARK: - Public API (Maintains backward compatibility)
/**
* Create combined public key data for exchange - exactly same format as iOS
* 96 bytes total: 32 (X25519) + 32 (Ed25519 signing) + 32 (Ed25519 identity)
* Get our static public key data (32 bytes for Noise)
* This replaces the old 96-byte combined key format
*/
fun getCombinedPublicKeyData(): ByteArray {
val combined = ByteArray(96)
System.arraycopy(publicKey.encoded, 0, combined, 0, 32) // X25519 key
System.arraycopy(signingPublicKey.encoded, 0, combined, 32, 32) // Ed25519 signing key
System.arraycopy(identityPublicKey.encoded, 0, combined, 64, 32) // Ed25519 identity key
return combined
return noiseService.getStaticPublicKeyData()
}
/**
* Add peer's combined public keys - exactly same logic as iOS
* Get our static public key for Noise protocol (for identity announcements)
*/
fun getStaticPublicKey(): ByteArray? {
return noiseService.getStaticPublicKeyData()
}
/**
* Get our signing public key for Ed25519 signatures (for identity announcements)
* Note: In the current implementation, this returns the same as static key
* In a full implementation, this would be a separate Ed25519 key
*/
fun getSigningPublicKey(): ByteArray? {
// For now, return the static public key as placeholder
// In a full implementation, this would be a separate Ed25519 signing key
return noiseService.getStaticPublicKeyData()
}
/**
* Sign data using our signing key (for identity announcements)
* Note: In the current simplified implementation, this returns empty signature
* In a full implementation, this would use Ed25519 signing
*/
fun signData(data: ByteArray): ByteArray? {
// For now, return empty signature as placeholder
// In a full implementation, this would use Ed25519 to sign the data
return ByteArray(64) // Ed25519 signature length placeholder
}
/**
* Add peer's public key and start handshake if needed
* For backward compatibility with old key exchange packets
*/
@Throws(Exception::class)
fun addPeerPublicKey(peerID: String, publicKeyData: ByteArray) {
if (publicKeyData.size != 96) {
throw Exception("Invalid public key data size: ${publicKeyData.size}, expected 96")
Log.d(TAG, "Legacy addPeerPublicKey called for $peerID with ${publicKeyData.size} bytes")
// If this is from old key exchange format, initiate new Noise handshake
if (!hasEstablishedSession(peerID)) {
Log.d(TAG, "No Noise session with $peerID, initiating handshake")
initiateHandshake(peerID)
}
// Extract all three keys: 32 for key agreement + 32 for signing + 32 for identity
val keyAgreementData = publicKeyData.sliceArray(0..31)
val signingKeyData = publicKeyData.sliceArray(32..63)
val identityKeyData = publicKeyData.sliceArray(64..95)
val peerPublicKey = X25519PublicKeyParameters(keyAgreementData, 0)
peerPublicKeys[peerID] = peerPublicKey
val peerSigningKey = Ed25519PublicKeyParameters(signingKeyData, 0)
peerSigningKeys[peerID] = peerSigningKey
val peerIdentityKey = Ed25519PublicKeyParameters(identityKeyData, 0)
peerIdentityKeys[peerID] = peerIdentityKey
// Generate shared secret for encryption using X25519
val agreement = X25519Agreement()
agreement.init(privateKey)
val sharedSecret = ByteArray(32)
agreement.calculateAgreement(peerPublicKey, sharedSecret, 0)
// Derive symmetric key using HKDF with same salt as iOS
val salt = "bitchat-v1".toByteArray()
val derivedKey = hkdf(sharedSecret, salt, byteArrayOf(), 32)
sharedSecrets[peerID] = derivedKey
}
/**
* Get peer's persistent identity key for favorites
* Get peer's identity key (fingerprint) for favorites
*/
fun getPeerIdentityKey(peerID: String): ByteArray? {
return peerIdentityKeys[peerID]?.encoded
val fingerprint = getPeerFingerprint(peerID) ?: return null
return fingerprint.toByteArray()
}
/**
* Clear persistent identity (for panic mode)
*/
fun clearPersistentIdentity() {
prefs.edit().remove("identity_key").apply()
noiseService.clearPersistentIdentity()
establishedSessions.clear()
}
/**
* Encrypt data for a specific peer using AES-256-GCM
* Encrypt data for a specific peer using Noise transport encryption
*/
@Throws(Exception::class)
fun encrypt(data: ByteArray, peerID: String): ByteArray {
val symmetricKey = sharedSecrets[peerID]
?: throw Exception("No shared secret for peer $peerID")
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val keySpec = SecretKeySpec(symmetricKey, "AES")
cipher.init(Cipher.ENCRYPT_MODE, keySpec)
val iv = cipher.iv
val ciphertext = cipher.doFinal(data)
// Combine IV and ciphertext (same format as iOS AES.GCM.SealedBox.combined)
val combined = ByteArray(iv.size + ciphertext.size)
System.arraycopy(iv, 0, combined, 0, iv.size)
System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size)
return combined
val encrypted = noiseService.encrypt(data, peerID)
if (encrypted == null) {
throw Exception("Failed to encrypt for $peerID")
}
return encrypted
}
/**
* Decrypt data from a specific peer
* Decrypt data from a specific peer using Noise transport encryption
*/
@Throws(Exception::class)
fun decrypt(data: ByteArray, peerID: String): ByteArray {
val symmetricKey = sharedSecrets[peerID]
?: throw Exception("No shared secret for peer $peerID")
if (data.size < 16) { // 12 bytes IV + 16 bytes tag minimum for GCM
throw Exception("Invalid encrypted data size")
val decrypted = noiseService.decrypt(data, peerID)
if (decrypted == null) {
throw Exception("Failed to decrypt from $peerID")
}
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val keySpec = SecretKeySpec(symmetricKey, "AES")
// Extract IV and ciphertext
val iv = data.sliceArray(0..11) // GCM IV is 12 bytes
val ciphertext = data.sliceArray(12 until data.size)
val gcmSpec = GCMParameterSpec(128, iv) // 128-bit authentication tag
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec)
return cipher.doFinal(ciphertext)
return decrypted
}
/**
* Sign data using Ed25519
* Sign data using our static identity key
* Note: This is now done at the packet level, not per-message
*/
@Throws(Exception::class)
fun sign(data: ByteArray): ByteArray {
val signer = Ed25519Signer()
signer.init(true, signingPrivateKey)
signer.update(data, 0, data.size)
return signer.generateSignature()
// Note: In Noise protocol, authentication is built into the handshake
// For compatibility, we return empty signature
return ByteArray(0)
}
/**
* Verify signature using Ed25519
* Verify signature using peer's identity key
* Note: This is now done at the packet level, not per-message
*/
@Throws(Exception::class)
fun verify(signature: ByteArray, data: ByteArray, peerID: String): Boolean {
val verifyingKey = peerSigningKeys[peerID]
?: throw Exception("No signing key for peer $peerID")
val signer = Ed25519Signer()
signer.init(false, verifyingKey)
signer.update(data, 0, data.size)
return signer.verifySignature(signature)
// Note: In Noise protocol, authentication is built into the transport
// Messages are authenticated automatically when decrypted
return hasEstablishedSession(peerID)
}
// MARK: - Noise Protocol Interface
/**
* Check if we have an established Noise session with a peer
*/
fun hasEstablishedSession(peerID: String): Boolean {
return noiseService.hasEstablishedSession(peerID)
}
/**
* HKDF implementation using SHA256 - same as iOS HKDF
* Get encryption icon state for UI
*/
private fun hkdf(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray {
// Extract
val hmac = javax.crypto.Mac.getInstance("HmacSHA256")
val saltKey = SecretKeySpec(if (salt.isEmpty()) ByteArray(32) else salt, "HmacSHA256")
hmac.init(saltKey)
val prk = hmac.doFinal(ikm)
// Expand
hmac.init(SecretKeySpec(prk, "HmacSHA256"))
val result = ByteArray(length)
var offset = 0
var counter = 1
while (offset < length) {
hmac.reset()
if (counter > 1) {
hmac.update(result, offset - 32, 32)
}
hmac.update(info)
hmac.update(counter.toByte())
val t = hmac.doFinal()
val remaining = length - offset
val toCopy = minOf(t.size, remaining)
System.arraycopy(t, 0, result, offset, toCopy)
offset += toCopy
counter++
}
return result
}
}
/**
* Message padding utilities - exact same as iOS version
*/
object MessagePadding {
// Standard block sizes for padding
private val blockSizes = listOf(256, 512, 1024, 2048)
/**
* Add PKCS#7-style padding to reach target size
*/
fun pad(data: ByteArray, targetSize: Int): ByteArray {
if (data.size >= targetSize) return data
val paddingNeeded = targetSize - data.size
// PKCS#7 only supports padding up to 255 bytes
if (paddingNeeded > 255) return data
val padded = ByteArray(targetSize)
System.arraycopy(data, 0, padded, 0, data.size)
// Fill with random bytes except the last byte
val random = SecureRandom()
random.nextBytes(padded.sliceArray(data.size until targetSize - 1))
// Last byte indicates padding length (PKCS#7)
padded[targetSize - 1] = paddingNeeded.toByte()
return padded
fun shouldShowEncryptionIcon(peerID: String): Boolean {
return hasEstablishedSession(peerID)
}
/**
* Remove padding from data
* Get peer fingerprint for favorites/blocking
*/
fun unpad(data: ByteArray): ByteArray {
if (data.isEmpty()) return data
// Last byte tells us how much padding to remove
val paddingLength = (data.last() and 0xFF.toByte()).toInt()
if (paddingLength <= 0 || paddingLength > data.size) return data
return data.sliceArray(0 until (data.size - paddingLength))
fun getPeerFingerprint(peerID: String): String? {
return noiseService.getPeerFingerprint(peerID)
}
/**
* Find optimal block size for data
* Get current peer ID for a fingerprint (for peer ID rotation)
*/
fun optimalBlockSize(dataSize: Int): Int {
// Account for encryption overhead (~16 bytes for AES-GCM tag)
val totalSize = dataSize + 16
fun getCurrentPeerID(fingerprint: String): String? {
return noiseService.getPeerID(fingerprint)
}
/**
* Initiate a Noise handshake with a peer
*/
fun initiateHandshake(peerID: String): ByteArray? {
Log.d(TAG, "🤝 Initiating Noise handshake with $peerID")
return noiseService.initiateHandshake(peerID)
}
/**
* Process an incoming handshake message
*/
fun processHandshakeMessage(data: ByteArray, peerID: String): ByteArray? {
Log.d(TAG, "🤝 Processing handshake message from $peerID")
return noiseService.processHandshakeMessage(data, peerID)
}
/**
* Remove a peer session (called when peer disconnects)
*/
fun removePeer(peerID: String) {
establishedSessions.remove(peerID)
noiseService.removePeer(peerID)
onSessionLost?.invoke(peerID)
Log.d(TAG, "🗑️ Removed session for $peerID")
}
/**
* Update peer ID mapping (for peer ID rotation)
*/
fun updatePeerIDMapping(oldPeerID: String?, newPeerID: String, fingerprint: String) {
oldPeerID?.let { establishedSessions.remove(it) }
establishedSessions[newPeerID] = fingerprint
noiseService.updatePeerIDMapping(oldPeerID, newPeerID, fingerprint)
}
// MARK: - Channel Encryption
/**
* Set password for a channel (derives encryption key using Argon2id)
*/
fun setChannelPassword(password: String, channel: String) {
noiseService.setChannelPassword(password, channel)
}
/**
* Encrypt message for a password-protected channel
*/
fun encryptChannelMessage(message: String, channel: String): ByteArray? {
return noiseService.encryptChannelMessage(message, channel)
}
/**
* Decrypt channel message
*/
fun decryptChannelMessage(encryptedData: ByteArray, channel: String): String? {
return noiseService.decryptChannelMessage(encryptedData, channel)
}
/**
* Remove channel password (when leaving channel)
*/
fun removeChannelPassword(channel: String) {
noiseService.removeChannelPassword(channel)
}
// MARK: - Session Management
/**
* Get all peers with established sessions
*/
fun getEstablishedPeers(): List<String> {
return establishedSessions.keys.toList()
}
/**
* Get sessions that need rekeying
*/
fun getSessionsNeedingRekey(): List<String> {
return noiseService.getSessionsNeedingRekey()
}
/**
* Initiate rekey for a session
*/
fun initiateRekey(peerID: String): ByteArray? {
Log.d(TAG, "🔄 Initiating rekey for $peerID")
establishedSessions.remove(peerID) // Will be re-added when new session is established
return noiseService.initiateRekey(peerID)
}
/**
* Get our identity fingerprint
*/
fun getIdentityFingerprint(): String {
return noiseService.getIdentityFingerprint()
}
/**
* Get debug information about encryption state
*/
fun getDebugInfo(): String = buildString {
appendLine("=== EncryptionService Debug ===")
appendLine("Established Sessions: ${establishedSessions.size}")
appendLine("Our Fingerprint: ${getIdentityFingerprint().take(16)}...")
// Find smallest block that fits
for (blockSize in blockSizes) {
if (totalSize <= blockSize) {
return blockSize
if (establishedSessions.isNotEmpty()) {
appendLine("Active Encrypted Sessions:")
establishedSessions.forEach { (peerID, fingerprint) ->
appendLine(" $peerID -> ${fingerprint.take(16)}...")
}
}
// For very large messages, just use the original size
return dataSize
appendLine("")
appendLine(noiseService.toString()) // Include NoiseService state
}
/**
* Shutdown encryption service
*/
fun shutdown() {
establishedSessions.clear()
noiseService.shutdown()
Log.d(TAG, "🔌 EncryptionService shut down")
}
}