From e62e85615a8f760359c16558491e41126fd70810 Mon Sep 17 00:00:00 2001 From: Florian Griffon Date: Sat, 5 Jul 2025 19:31:40 +0200 Subject: [PATCH] feat: XK and XX handshake using noise lib --- .../{NoiseHandler.kt => NoiseHandlerXK.kt} | 48 ++--- .../kotlin/com/icing/dialer/NoiseHandlerXX.kt | 172 ++++++++++++++++++ 2 files changed, 198 insertions(+), 22 deletions(-) rename dialer/android/app/src/main/kotlin/com/icing/dialer/{NoiseHandler.kt => NoiseHandlerXK.kt} (76%) create mode 100644 dialer/android/app/src/main/kotlin/com/icing/dialer/NoiseHandlerXX.kt diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/NoiseHandler.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/NoiseHandlerXK.kt similarity index 76% rename from dialer/android/app/src/main/kotlin/com/icing/dialer/NoiseHandler.kt rename to dialer/android/app/src/main/kotlin/com/icing/dialer/NoiseHandlerXK.kt index 1401a51..3fb7eaa 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/NoiseHandler.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/NoiseHandlerXK.kt @@ -10,9 +10,9 @@ import javax.crypto.ShortBufferException import java.security.NoSuchAlgorithmException import java.util.Arrays -class NoiseHandler( - private val localKeyBase64: String, // ED25519 private (initiator) or public (responder) key (Base64-encoded) - private val remotePublicKeyBase64: String // Remote ED25519 public key (Base64-encoded) +class NoiseHandlerXK( + private val localKeyBase64: String, // ED25519 private (initiator) or private (responder) key (Base64-encoded) + private val remotePublicKeyBase64: String? // Remote ED25519 public key (Base64-encoded, required for initiator only) ) { private var handshakeState: HandshakeState? = null private var cipherStatePair: CipherStatePair? = null @@ -25,7 +25,7 @@ class NoiseHandler( } /** - * Initializes the Noise handshake. + * Initializes the Noise handshake with the XK pattern. * @param isInitiator True if this is the initiator, false if responder. * @return The initial handshake message. * @throws IllegalArgumentException If keys are invalid. @@ -35,36 +35,38 @@ class NoiseHandler( var localKey: ByteArray? = null var remotePublicKey: ByteArray? = null try { - val protocolName = "Noise_IK_25519_AESGCM_SHA256" + val protocolName = "Noise_XK_25519_AESGCM_SHA256" handshakeState = HandshakeState( protocolName, if (isInitiator) HandshakeState.INITIATOR else HandshakeState.RESPONDER ) - // Set local key (private for initiator, public for responder) + // Set local key (private key for both initiator and responder in XK) localKey = Base64.decode(localKeyBase64, Base64.DEFAULT) if (localKey.size != 32) { throw IllegalArgumentException("Invalid local key size: ${localKey.size}") } - if (isInitiator) { - handshakeState?.localKeyPair?.setPrivateKey(localKey, 0) - ?: throw IllegalStateException("Local key pair not initialized") - } else { - handshakeState?.localKeyPair?.setPublicKey(localKey, 0) - ?: throw IllegalStateException("Local key pair not initialized") - } + handshakeState?.localKeyPair?.setPrivateKey(localKey, 0) + ?: throw IllegalStateException("Local key pair not initialized") - // Set remote public key - remotePublicKey = Base64.decode(remotePublicKeyBase64, Base64.DEFAULT) - if (remotePublicKey.size != 32) { - throw IllegalArgumentException("Invalid remote public key size: ${remotePublicKey.size}") + // Set remote public key (required for initiator only in XK) + if (isInitiator) { + if (remotePublicKeyBase64 == null) { + throw IllegalArgumentException("Remote public key required for initiator") + } + remotePublicKey = Base64.decode(remotePublicKeyBase64, Base64.DEFAULT) + if (remotePublicKey.size != 32) { + throw IllegalArgumentException("Invalid remote public key size: ${remotePublicKey.size}") + } + handshakeState?.remotePublicKey?.setPublicKey(remotePublicKey, 0) + ?: throw IllegalStateException("Remote public key not initialized") } - handshakeState?.remotePublicKey?.setPublicKey(remotePublicKey, 0) - ?: throw IllegalStateException("Remote public key not initialized") // Start handshake and write initial message handshakeState?.start() ?: throw IllegalStateException("Handshake state not initialized") - val messageBuffer = ByteArray(256) // Sufficient for IK initial message + val keyLength = handshakeState?.localKeyPair?.getPublicKeyLength() ?: 32 + val macLength = 16 // AES-GCM MAC length + val messageBuffer = ByteArray(keyLength + macLength + 128) // Conservative sizing val payload = ByteArray(0) // Empty payload val writtenLength: Int = handshakeState?.writeMessage( messageBuffer, 0, payload, 0, payload.size @@ -90,7 +92,9 @@ class NoiseHandler( fun processHandshakeMessage(message: ByteArray): ByteArray? { try { val handshake = handshakeState ?: throw IllegalStateException("Handshake not initialized") - val messageBuffer = ByteArray(256) // Sufficient for IK payload + MAC + val keyLength = handshake.localKeyPair?.getPublicKeyLength() ?: 32 + val macLength = 16 // AES-GCM MAC length + val messageBuffer = ByteArray(keyLength + macLength + 128) // Conservative sizing val writtenLength: Int = handshake.readMessage( message, 0, message.size, messageBuffer, 0 ) @@ -102,7 +106,7 @@ class NoiseHandler( // Write next message val payload = ByteArray(0) // Empty payload - val nextMessage = ByteArray(256) + val nextMessage = ByteArray(keyLength + macLength + 128) val nextWrittenLength: Int = handshake.writeMessage( nextMessage, 0, payload, 0, payload.size ) diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/NoiseHandlerXX.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/NoiseHandlerXX.kt new file mode 100644 index 0000000..6dd5f6f --- /dev/null +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/NoiseHandlerXX.kt @@ -0,0 +1,172 @@ +package com.icing.dialer + +import android.util.Base64 +import com.southernstorm.noise.protocol.CipherStatePair +import com.southernstorm.noise.protocol.HandshakeState +import com.southernstorm.noise.protocol.Noise +import java.util.Arrays +import javax.crypto.BadPaddingException +import javax.crypto.ShortBufferException +import java.security.NoSuchAlgorithmException + +class NoiseHandlerXX( + private val localKeyBase64: String // ED25519 private key (Base64-encoded) +) { + private var handshakeState: HandshakeState? = null + private var cipherStatePair: CipherStatePair? = null + private var remotePublicKeyBase64: String? = null + + /** + * Wipes sensitive data by filling the byte array with zeros. + */ + private fun wipe(data: ByteArray?) { + data?.let { Arrays.fill(it, 0.toByte()) } + } + + /** + * Initializes the Noise XX handshake. + * @param isInitiator True if this is the initiator, false if responder. + * @return The initial handshake message. + * @throws IllegalArgumentException If local key is invalid. + * @throws IllegalStateException If handshake fails to start. + */ + fun initialize(isInitiator: Boolean): ByteArray { + var localKey: ByteArray? = null + try { + val protocolName = "Noise_XX_25519_AESGCM_SHA256" + handshakeState = HandshakeState( + protocolName, + if (isInitiator) HandshakeState.INITIATOR else HandshakeState.RESPONDER + ) + + // Set local private key + localKey = Base64.decode(localKeyBase64, Base64.DEFAULT) + if (localKey.size != 32) { + throw IllegalArgumentException("Invalid local key size: ${localKey.size}") + } + handshakeState?.localKeyPair?.setPrivateKey(localKey, 0) + ?: throw IllegalStateException("Local key pair not initialized") + + // Start handshake and write initial message + handshakeState?.start() ?: throw IllegalStateException("Handshake state not initialized") + val keyLength = handshakeState?.localKeyPair?.getPublicKeyLength() ?: 32 + val macLength = 16 // AES-GCM MAC length + val messageBuffer = ByteArray(keyLength + macLength + 128) // Conservative sizing + val payload = ByteArray(0) // Empty payload + val writtenLength: Int = handshakeState?.writeMessage( + messageBuffer, 0, payload, 0, payload.size + ) ?: throw IllegalStateException("Failed to write handshake message") + return messageBuffer.copyOf(writtenLength) + } catch (e: NoSuchAlgorithmException) { + throw IllegalStateException("Unsupported algorithm: ${e.message}", e) + } catch (e: ShortBufferException) { + throw IllegalStateException("Buffer too small for handshake message", e) + } finally { + wipe(localKey) + } + } + + /** + * Processes a handshake message and returns the next message or null if complete. + * @param message The received handshake message. + * @return The next handshake message, null if handshake is complete, or the remote public key (Base64-encoded). + * @throws IllegalStateException If handshake state is invalid. + * @throws BadPaddingException If message decryption fails. + */ + fun processHandshakeMessage(message: ByteArray): Pair { + var remotePublicKey: ByteArray? = null + try { + val handshake = handshakeState ?: throw IllegalStateException("Handshake not initialized") + val keyLength = handshake.localKeyPair?.getPublicKeyLength() ?: 32 + val macLength = 16 // AES-GCM MAC length + val messageBuffer = ByteArray(keyLength + macLength + 128) // Conservative sizing + val payload = ByteArray(0) // Empty payload + val writtenLength: Int = handshake.readMessage( + message, 0, message.size, messageBuffer, 0 + ) + + // Extract remote public key after reading message + if (handshake.remotePublicKey.hasPublicKey()) { + remotePublicKey = ByteArray(keyLength) + handshake.remotePublicKey.getPublicKey(remotePublicKey, 0) + remotePublicKeyBase64 = Base64.encodeToString(remotePublicKey, Base64.DEFAULT) + } + + if (handshake.getAction() == HandshakeState.SPLIT) { + cipherStatePair = handshake.split() + return Pair(null, remotePublicKeyBase64) // Handshake complete + } + + // Write next message + val nextMessage = ByteArray(keyLength + macLength + 128) + val nextWrittenLength: Int = handshake.writeMessage( + nextMessage, 0, payload, 0, payload.size + ) + return Pair(nextMessage.copyOf(nextWrittenLength), remotePublicKeyBase64) + } catch (e: ShortBufferException) { + throw IllegalStateException("Buffer too small for handshake message", e) + } catch (e: BadPaddingException) { + throw IllegalStateException("Invalid handshake message: ${e.message}", e) + } finally { + wipe(remotePublicKey) + } + } + + /** + * Encrypts data using the sender's cipher state. + * @param data The data to encrypt. + * @return The encrypted data. + * @throws IllegalStateException If handshake is not completed. + */ + fun encryptData(data: ByteArray): ByteArray { + val cipherState = cipherStatePair?.getSender() + ?: throw IllegalStateException("Handshake not completed") + try { + val outputBuffer = ByteArray(data.size + cipherState.getMACLength()) // Account for AES-GCM MAC + val length: Int = cipherState.encryptWithAd(null, data, 0, outputBuffer, 0, data.size) + return outputBuffer.copyOf(length) + } catch (e: ShortBufferException) { + throw IllegalStateException("Buffer too small for encryption: ${e.message}", e) + } + } + + /** + * Decrypts data using the receiver's cipher state. + * @param data The encrypted data. + * @return The decrypted data. + * @throws IllegalStateException If handshake is not completed. + * @throws BadPaddingException If decryption fails. + */ + fun decryptData(data: ByteArray): ByteArray { + val cipherState = cipherStatePair?.getReceiver() + ?: throw IllegalStateException("Handshake not completed") + try { + val outputBuffer = ByteArray(data.size) + val length: Int = cipherState.decryptWithAd(null, data, 0, outputBuffer, 0, data.size) + return outputBuffer.copyOf(length) + } catch (e: ShortBufferException) { + throw IllegalStateException("Buffer too small for decryption: ${e.message}", e) + } catch (e: BadPaddingException) { + throw IllegalStateException("Invalid ciphertext: ${e.message}", e) + } + } + + /** + * Returns the remote public key received during the handshake. + * @return The Base64-encoded remote public key, or null if not yet received. + */ + fun getRemotePublicKey(): String? { + return remotePublicKeyBase64 + } + + /** + * Cleans up sensitive cryptographic data. + */ + fun destroy() { + handshakeState?.destroy() + cipherStatePair?.destroy() + handshakeState = null + cipherStatePair = null + remotePublicKeyBase64 = null + } +} \ No newline at end of file