From c5573e9f25f2a7f394ffa3fd3f1f920b9a4d31ee Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 1 Feb 2026 22:36:20 +0300 Subject: [PATCH] Implement encryption protocol Signed-off-by: denis0001-dev --- app/shared/build.gradle.kts | 3 + .../crypto/backup/BackupCrypto.android.kt | 87 ++++++ .../ru/fromchat/crypto/dm/DmCrypto.android.kt | 44 +++ .../transport/TransportCrypto.android.kt | 41 +++ .../ru/fromchat/ui/debug/DebugApiScreen.kt | 273 ++++++++++++++++++ .../composeResources/values/strings.xml | 12 + .../kotlin/ru/fromchat/api/ApiClient.kt | 158 ++++++++++ .../kotlin/ru/fromchat/api/Models.kt | 124 ++++++++ .../kotlin/ru/fromchat/crypto/DmCrypto.kt | 48 +++ .../ru/fromchat/crypto/IdentityKeyManager.kt | 206 +++++++++++++ .../ru/fromchat/crypto/backup/BackupCrypto.kt | 126 ++++++++ .../kotlin/ru/fromchat/crypto/dm/DmCrypto.kt | 31 ++ .../crypto/transport/TransportCrypto.kt | 28 ++ .../commonMain/kotlin/ru/fromchat/ui/App.kt | 5 + .../kotlin/ru/fromchat/ui/auth/LoginScreen.kt | 16 +- .../ru/fromchat/ui/debug/DebugApiScreen.kt | 7 + .../kotlin/ru/fromchat/ui/main/SettingsTab.kt | 15 + .../crypto/backup/BackupCrypto.ios.kt | 29 ++ .../ru/fromchat/crypto/dm/DmCrypto.ios.kt | 79 +++++ .../crypto/transport/TransportCrypto.ios.kt | 14 + .../fromchat/ui/debug/DebugApiScreen.ios.kt | 9 + gradle/libs.versions.toml | 2 + 22 files changed, 1354 insertions(+), 3 deletions(-) create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.ios.kt diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 3ac3d23..6bd8f6d 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -37,6 +37,8 @@ kotlin { androidMain.dependencies { implementation(libs.ktor.client.okhttp) + // NaCl box implementation for transport encryption (Android/JVM only) + implementation("org.purejava:tweetnacl-java:1.1.3") } commonMain.dependencies { @@ -73,6 +75,7 @@ kotlin { implementation(libs.coil.network.ktor3) implementation(project(":utils:shared")) + implementation(libs.krypto) } iosMain.dependencies { diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.android.kt new file mode 100644 index 0000000..c98fa33 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.android.kt @@ -0,0 +1,87 @@ +package ru.fromchat.crypto.backup + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec + +actual object BackupCrypto { + private val random = SecureRandom() + + actual suspend fun encryptBackupWithPassword(password: String, bundle: PrivateKeyBundle): EncryptedBackupBlob = + withContext(Dispatchers.Default) { + // Generate salt + val salt = ByteArray(16) + random.nextBytes(salt) + + // Derive KEK using PBKDF2 + val spec = PBEKeySpec(password.toCharArray(), salt, 210_000, 256) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val kek = factory.generateSecret(spec).encoded + + // Serialize bundle + val serialized = serializeBundle(bundle) + + // Encrypt with AES-GCM + val nonce = ByteArray(12) + random.nextBytes(nonce) + val secretKey = SecretKeySpec(kek, "AES") + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val parameterSpec = GCMParameterSpec(128, nonce) + cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec) + val ciphertext = cipher.doFinal(serialized) + + EncryptedBackupBlob(salt, nonce, ciphertext) + } + + actual suspend fun decryptBackupWithPassword(password: String, blob: EncryptedBackupBlob): PrivateKeyBundle = + withContext(Dispatchers.Default) { + // Derive KEK using PBKDF2 + val spec = PBEKeySpec(password.toCharArray(), blob.salt, 210_000, 256) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val kek = factory.generateSecret(spec).encoded + + // Decrypt with AES-GCM + val secretKey = SecretKeySpec(kek, "AES") + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val parameterSpec = GCMParameterSpec(128, blob.iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec) + val plaintext = cipher.doFinal(blob.ciphertext) + + // Deserialize bundle + deserializeBundle(plaintext) + } + + actual fun randomBytes(length: Int): ByteArray { + val bytes = ByteArray(length) + random.nextBytes(bytes) + return bytes + } +} + +// Platform-specific AES-GCM operations for DM crypto +object BackupCryptoPlatform { + suspend fun aesGcmEncrypt(key: ByteArray, plaintext: ByteArray, iv: ByteArray? = null): Pair = + withContext(Dispatchers.Default) { + val nonce = iv ?: ByteArray(12).also { SecureRandom().nextBytes(it) } + val secretKey = SecretKeySpec(key, "AES") + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val parameterSpec = GCMParameterSpec(128, nonce) + cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec) + val ciphertext = cipher.doFinal(plaintext) + Pair(nonce, ciphertext) + } + + suspend fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): ByteArray = + withContext(Dispatchers.Default) { + val secretKey = SecretKeySpec(key, "AES") + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val parameterSpec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec) + cipher.doFinal(ciphertext) + } +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.android.kt new file mode 100644 index 0000000..0d07df5 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.android.kt @@ -0,0 +1,44 @@ +package ru.fromchat.crypto.dm + +import com.pr0gramm3r101.utils.crypto.Base64 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import ru.fromchat.crypto.backup.BackupCryptoPlatform + +actual object DmCrypto { + private const val AES_KEY_SIZE = 32 + private const val GCM_IV_SIZE = 12 + private const val GCM_TAG_SIZE = 16 + + actual suspend fun unwrapMek( + wrappedMekB64: String, + wrappingKey: ByteArray + ): ByteArray = withContext(Dispatchers.Default) { + require(wrappingKey.size == AES_KEY_SIZE) { "Wrapping key must be 32 bytes" } + + val wrapped = Base64.decode(wrappedMekB64) + require(wrapped.size >= GCM_IV_SIZE + GCM_TAG_SIZE) { "Wrapped MEK too short" } + + val iv = wrapped.sliceArray(0 until GCM_IV_SIZE) + val ciphertext = wrapped.sliceArray(GCM_IV_SIZE until wrapped.size) + + BackupCryptoPlatform.aesGcmDecrypt(wrappingKey, iv, ciphertext) + } + + actual suspend fun decryptEnvelope( + ivB64: String, + ciphertextB64: String, + mek: ByteArray + ): ByteArray = withContext(Dispatchers.Default) { + require(mek.size == AES_KEY_SIZE) { "MEK must be 32 bytes" } + + val iv = Base64.decode(ivB64) + val ciphertext = Base64.decode(ciphertextB64) + + require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" } + require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" } + + BackupCryptoPlatform.aesGcmDecrypt(mek, iv, ciphertext) + } +} + diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.android.kt new file mode 100644 index 0000000..b09646f --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.android.kt @@ -0,0 +1,41 @@ +package ru.fromchat.crypto.transport + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.iwebpp.crypto.TweetNaclFast +import java.security.SecureRandom +import java.util.Base64 + +actual object TransportCrypto { + private val random = SecureRandom() + + actual suspend fun encryptWithTransportKey( + plaintext: String, + transportPublicKeyB64: String + ): TransportCiphertext = withContext(Dispatchers.Default) { + val messageBytes = plaintext.encodeToByteArray() + + // Decode server-provided transport public key + val transportPublicKey = Base64.getDecoder().decode(transportPublicKeyB64) + + // Ephemeral X25519 keypair for this message + val keyPair = TweetNaclFast.Box.keyPair() + + // NaCl box with (server transport public key, our ephemeral secret key) + val box = TweetNaclFast.Box(transportPublicKey, keyPair.secretKey) + + // 24-byte nonce as required by NaCl box + val nonce = ByteArray(TweetNaclFast.Box.nonceLength) + random.nextBytes(nonce) + + val ciphertext = box.box(messageBytes, nonce) + + val encoder = Base64.getEncoder() + TransportCiphertext( + clientPublicKeyB64 = encoder.encodeToString(keyPair.publicKey), + nonceB64 = encoder.encodeToString(nonce), + ciphertextB64 = encoder.encodeToString(ciphertext) + ) + } +} + diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt new file mode 100644 index 0000000..365f937 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt @@ -0,0 +1,273 @@ +package ru.fromchat.ui.debug + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.utils.settings.settings +import kotlinx.coroutines.launch +import ru.fromchat.api.ApiClient +import ru.fromchat.crypto.IdentityKeyManager +import ru.fromchat.crypto.decryptEnvelope +import ru.fromchat.ui.LocalNavController + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun DebugApiScreen() { + val navController = LocalNavController.current + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + + var statusMessage by rememberSaveable { mutableStateOf("") } + var profileResult by rememberSaveable { mutableStateOf(null) } + var conversationsResult by rememberSaveable { mutableStateOf(null) } + var historyResult by rememberSaveable { mutableStateOf(null) } + var historyUserId by rememberSaveable { mutableStateOf("0") } + var decryptedMessage by rememberSaveable { mutableStateOf(null) } + var sendRecipientId by rememberSaveable { mutableStateOf("") } + var sendMessageText by rememberSaveable { mutableStateOf("") } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = null) + } + }, + title = { + Text(text = "Debug API") + }, + actions = { + IconButton(onClick = {}) { + Icon(imageVector = Icons.Filled.BugReport, contentDescription = null) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = statusMessage.takeIf { it.isNotBlank() } + ?: "Status will appear here", + style = MaterialTheme.typography.bodyMedium + ) + + Button( + onClick = { + scope.launch { + runCatching { ApiClient.getOwnProfile() } + .onSuccess { + profileResult = ApiClient.json.encodeToString(it) + statusMessage = "Profile loaded" + } + .onFailure { + statusMessage = it.message ?: "An unknown error occurred" + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Load own profile") + } + + Text( + text = profileResult ?: "No data loaded yet", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { + scope.launch { + runCatching { ApiClient.getDmConversations() } + .onSuccess { + conversationsResult = ApiClient.json.encodeToString(it) + statusMessage = "Conversations loaded" + } + .onFailure { + statusMessage = it.message ?: "An unknown error occurred" + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Load DM conversations") + } + + Text( + text = conversationsResult ?: "No data loaded yet", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = historyUserId, + onValueChange = { historyUserId = it }, + label = { Text(text = "History user ID") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { + val userId = historyUserId.toIntOrNull() + if (userId == null) { + statusMessage = "Enter a valid user ID" + return@Button + } + + scope.launch { + runCatching { ApiClient.getDmHistory(userId) } + .onSuccess { + historyResult = ApiClient.json.encodeToString(it) + statusMessage = "DM history loaded" + } + .onFailure { + statusMessage = it.message ?: "An unknown error occurred" + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Load DM history") + } + + Text( + text = historyResult ?: "No data loaded yet", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + + Divider(modifier = Modifier.padding(vertical = 8.dp)) + + Text( + text = "DM Send Test", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = sendRecipientId, + onValueChange = { sendRecipientId = it }, + label = { Text(text = "Recipient user ID") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = sendMessageText, + onValueChange = { sendMessageText = it }, + label = { Text(text = "Message text") }, + singleLine = false, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { + val userId = sendRecipientId.toIntOrNull() + if (userId == null) { + statusMessage = "Enter a valid recipient ID" + return@Button + } + if (sendMessageText.isBlank()) { + statusMessage = "Enter a message to send" + return@Button + } + + scope.launch { + runCatching { + ApiClient.sendDm( + recipientId = userId, + plaintext = sendMessageText.trim(), + replyToId = null + ) + }.onSuccess { + statusMessage = "DM sent successfully" + }.onFailure { + statusMessage = it.message ?: "Failed to send DM" + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Send DM") + } + + Divider(modifier = Modifier.padding(vertical = 8.dp)) + + Text( + text = "DM Decryption Test", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { + val userId = historyUserId.toIntOrNull() + if (userId == null) { + statusMessage = "Enter a valid user ID" + return@Button + } + + scope.launch { + try { + // Restore keys from local storage if available + IdentityKeyManager.restoreFromLocal() + + // Fetch DM history + val history = ApiClient.getDmHistory(userId) + if (history.messages.isEmpty()) { + statusMessage = "No messages found" + decryptedMessage = null + return@launch + } + + // Get current user ID + val currentUserId = settings.getInt("current_user_id", 0) + if (currentUserId == 0) { + statusMessage = "Current user ID not found" + decryptedMessage = null + return@launch + } + + // Try to decrypt the first envelope + val firstEnvelope = history.messages.first() + val plaintext = decryptEnvelope(firstEnvelope, currentUserId) + decryptedMessage = "Decrypted: $plaintext" + statusMessage = "Decryption successful" + } catch (e: Exception) { + statusMessage = "Decryption failed: ${e.message}" + decryptedMessage = "Error: ${e.message}\n${e.stackTraceToString()}" + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Decrypt First DM") + } + + Text( + text = decryptedMessage ?: "No decrypted message yet", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + } + } +} diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index 45c5c64..88a8039 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -56,6 +56,18 @@ As system Light Dark + Debug API + Inspect profile and DM endpoints used by the client. + Status will appear here + Profile loaded + Load own profile + No data loaded yet + Conversations loaded + Load DM conversations + History user ID + Enter a valid user ID + DM history loaded + Load DM history Unexpected error diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index d3b1c93..344c3ed 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -19,6 +19,7 @@ import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.setBody +import io.ktor.client.request.put import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json @@ -30,6 +31,9 @@ import ru.fromchat.core.config.Config import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable import kotlin.concurrent.Volatile import kotlin.time.Duration.Companion.milliseconds +import com.pr0gramm3r101.utils.crypto.Base64 +import ru.fromchat.crypto.IdentityKeyManager +import ru.fromchat.crypto.transport.TransportCrypto /** * Creates a platform-specific HTTP client that supports WebSockets @@ -158,6 +162,160 @@ object ApiClient { } .body() + suspend fun getOwnProfile(): UserProfile = + http + .get("${Config.apiBaseUrl}/user/profile") { + contentType(ContentType.Application.Json) + } + .body() + + suspend fun getProfileById(userId: Int): UserProfile = + http + .get("${Config.apiBaseUrl}/user/id/$userId") { + contentType(ContentType.Application.Json) + } + .body() + + suspend fun getDmConversations(): List = + http + .get("${Config.apiBaseUrl}/dm/conversations") { + contentType(ContentType.Application.Json) + } + .body() + .conversations + + suspend fun getDmHistory( + otherUserId: Int, + limit: Int = 50, + beforeId: Int? = null + ): DmHistoryResponse = + http + .get("${Config.apiBaseUrl}/dm/history/$otherUserId") { + contentType(ContentType.Application.Json) + parameter("limit", limit) + beforeId?.let { parameter("before_id", it) } + } + .body() + + suspend fun getOwnPublicKey(): PublicKeyResponse = + http + .get("${Config.apiBaseUrl}/crypto/public-key") { + contentType(ContentType.Application.Json) + } + .body() + + suspend fun getUserPublicKey(userId: Int): PublicKeyResponse = + http + .get("${Config.apiBaseUrl}/crypto/public-key/of/$userId") { + contentType(ContentType.Application.Json) + } + .body() + + private suspend fun getTransportPublicKey(): TransportKeyResponse = + http + .get("${Config.apiBaseUrl}/dm/key/transport/public") { + contentType(ContentType.Application.Json) + } + .body() + + /** + * Send a direct message with transport-layer encryption, mirroring the Web client's /dm/send flow. + */ + suspend fun sendDm( + recipientId: Int, + plaintext: String, + replyToId: Int? = null + ) { + val keys = IdentityKeyManager.getCurrentKeys() + ?: IdentityKeyManager.restoreFromLocal() + ?: error("Identity keys not initialized. Please log in again.") + + val recipientPublicKey = getUserPublicKey(recipientId).publicKey + ?: error("Recipient public key not found") + val transportKey = getTransportPublicKey() + + val transportCipher = TransportCrypto.encryptWithTransportKey( + plaintext = plaintext, + transportPublicKeyB64 = transportKey.publicKeyB64 + ) + + val senderPublicKeyB64 = Base64.encode(keys.publicKey) + + val body = SendDmRequest( + recipientId = recipientId, + clientPublicKeyB64 = transportCipher.clientPublicKeyB64, + transportNonceB64 = transportCipher.nonceB64, + transportCiphertextB64 = transportCipher.ciphertextB64, + senderPublicKeyB64 = senderPublicKeyB64, + recipientPublicKeyB64 = recipientPublicKey, + replyToId = replyToId, + transportFiles = emptyList() + ) + + http.post("${Config.apiBaseUrl}/dm/send") { + contentType(ContentType.Application.Json) + setBody(body) + } + } + + /** + * Edit an existing direct message using the same transport encryption scheme as /dm/send. + */ + suspend fun editDm( + messageId: Int, + recipientId: Int, + plaintext: String + ) { + val keys = IdentityKeyManager.getCurrentKeys() + ?: IdentityKeyManager.restoreFromLocal() + ?: error("Identity keys not initialized. Please log in again.") + + val recipientPublicKey = getUserPublicKey(recipientId).publicKey + ?: error("Recipient public key not found") + val transportKey = getTransportPublicKey() + + val transportCipher = TransportCrypto.encryptWithTransportKey( + plaintext = plaintext, + transportPublicKeyB64 = transportKey.publicKeyB64 + ) + + val senderPublicKeyB64 = Base64.encode(keys.publicKey) + + val body = EditDmRequest( + clientPublicKeyB64 = transportCipher.clientPublicKeyB64, + transportNonceB64 = transportCipher.nonceB64, + transportCiphertextB64 = transportCipher.ciphertextB64, + senderPublicKeyB64 = senderPublicKeyB64, + recipientPublicKeyB64 = recipientPublicKey + ) + + http.put("${Config.apiBaseUrl}/dm/edit/$messageId") { + contentType(ContentType.Application.Json) + setBody(body) + } + } + + suspend fun fetchBackupBlob(): String? { + return try { + val response = http.get("${Config.apiBaseUrl}/crypto/backup") { + contentType(ContentType.Application.Json) + } + val backupResponse = response.body() + backupResponse.blob + } catch (e: Exception) { + ru.fromchat.core.Logger.d("ApiClient", "No backup found or error fetching: ${e.message}") + null + } + } + + suspend fun uploadBackupBlob(blobJson: String) { + val payload = BackupBlobRequest(blob = blobJson) + http.post("${Config.apiBaseUrl}/crypto/backup") { + contentType(ContentType.Application.Json) + setBody(payload) + } + } + // Validate token by fetching user profile suspend fun validateToken(): Boolean { try { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index 6a04866..30655d2 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -1,5 +1,6 @@ package ru.fromchat.api +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement @@ -34,6 +35,38 @@ data class User( val profile_picture: String? = null ) +@Serializable +data class UserProfile( + val id: Int, + val username: String, + @SerialName("display_name") val displayName: String? = null, + @SerialName("profile_picture") val profilePicture: String? = null, + val bio: String? = null, + val online: Boolean = false, + @SerialName("last_seen") val lastSeen: String? = null, + @SerialName("created_at") val createdAt: String? = null, + val verified: Boolean? = null, + val suspended: Boolean? = null, + @SerialName("suspension_reason") val suspensionReason: String? = null, + val deleted: Boolean? = null +) + +@Serializable +data class ProfileDialogData( + @SerialName("user_id") val userId: Int? = null, + val username: String? = null, + @SerialName("display_name") val displayName: String? = null, + @SerialName("profile_picture") val profilePicture: String? = null, + val bio: String? = null, + @SerialName("member_since") val memberSince: String? = null, + val online: Boolean? = null, + @SerialName("is_own_profile") val isOwnProfile: Boolean = false, + val verified: Boolean? = null, + val suspended: Boolean? = null, + @SerialName("suspension_reason") val suspensionReason: String? = null, + val deleted: Boolean? = null +) + @Serializable data class LoginResponse( val user: User, @@ -122,6 +155,87 @@ data class TypingData( val username: String ) +@Serializable +data class DmFile( + val id: Int, + val name: String, + val path: String, + @SerialName("dm_envelope_id") val dmEnvelopeId: Int? = null, + @SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null, + @SerialName("nonce_b64") val nonceB64: String? = null +) + +@Serializable +data class DmEnvelope( + val id: Int, + val senderId: Int, + val recipientId: Int, + @SerialName("iv_b64") val ivB64: String, + @SerialName("ciphertext_b64") val ciphertextB64: String, + @SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null, + val timestamp: String, + val replyToId: Int? = null, + val files: List? = null +) + +@Serializable +data class DmConversation( + val user: User, + val lastMessage: DmEnvelope, + val unreadCount: Int +) + +@Serializable +data class DmConversationsResponse( + val conversations: List = emptyList() +) + +@Serializable +data class DmHistoryResponse( + val messages: List = emptyList(), + @SerialName("has_more") val hasMore: Boolean? = null +) + +@Serializable +data class PublicKeyResponse( + val publicKey: String? = null +) + +@Serializable +data class SendDmFile( + @SerialName("encrypted_file_data_b64") val encryptedFileDataB64: String, + val filename: String, + @SerialName("file_size") val fileSize: Long +) + +@Serializable +data class SendDmRequest( + @SerialName("recipient_id") val recipientId: Int, + @SerialName("client_public_key_b64") val clientPublicKeyB64: String, + @SerialName("transport_nonce_b64") val transportNonceB64: String, + @SerialName("transport_ciphertext_b64") val transportCiphertextB64: String, + @SerialName("sender_public_key_b64") val senderPublicKeyB64: String, + @SerialName("recipient_public_key_b64") val recipientPublicKeyB64: String, + @SerialName("reply_to_id") val replyToId: Int? = null, + @SerialName("transport_files") val transportFiles: List = emptyList() +) + +@Serializable +data class EditDmRequest( + @SerialName("client_public_key_b64") val clientPublicKeyB64: String, + @SerialName("transport_nonce_b64") val transportNonceB64: String, + @SerialName("transport_ciphertext_b64") val transportCiphertextB64: String, + @SerialName("sender_public_key_b64") val senderPublicKeyB64: String, + @SerialName("recipient_public_key_b64") val recipientPublicKeyB64: String +) + +@Serializable +data class TransportKeyResponse( + @SerialName("key_id") val keyId: String, + @SerialName("public_key_b64") val publicKeyB64: String, + @SerialName("created_at") val createdAt: Double? = null +) + // Batched updates message @Serializable data class UpdateItem( @@ -153,4 +267,14 @@ data class WebSocketEditMessageRequest( @Serializable data class WebSocketDeleteMessageRequest( val message_id: Int +) + +@Serializable +data class BackupBlobResponse( + val blob: String? +) + +@Serializable +data class BackupBlobRequest( + val blob: String ) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt new file mode 100644 index 0000000..1e5da5b --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt @@ -0,0 +1,48 @@ +package ru.fromchat.crypto + +import com.pr0gramm3r101.utils.crypto.PasswordHash +import ru.fromchat.api.DmEnvelope +import ru.fromchat.crypto.dm.DmCrypto + +/** + * Unwrap a MEK (Message Encryption Key) using the appropriate wrapping key + * Matches Web implementation: derive wrapping key from our public key using HKDF + */ +suspend fun unwrapMek(wrappedMekB64: String, envelope: DmEnvelope, currentUserId: Int?): ByteArray { + val keys = IdentityKeyManager.getCurrentKeys() + ?: IdentityKeyManager.restoreFromLocal() + ?: throw IllegalStateException("Identity keys not initialized. Call ensureKeysOnLogin first.") + + // Determine context based on whether we're sender or recipient + val isRecipient = envelope.recipientId == currentUserId + val context = if (isRecipient) "recipient_wrap_key" else "sender_wrap_key" + + // Derive wrapping key from our public key using HKDF + // Salt: 16 zero bytes, Info: context string UTF-8 bytes + val salt = ByteArray(16) // zeros + val info = context.encodeToByteArray() + val wrappingKeyRaw = PasswordHash.hkdfExtractAndExpand( + inputKeyMaterial = keys.publicKey, + salt = salt, + info = info, + length = 32 + ) + + // Unwrap the MEK using platform-specific AES-GCM implementation + return DmCrypto.unwrapMek(wrappedMekB64, wrappingKeyRaw) +} + +/** + * Decrypt a DM envelope to plaintext + */ +suspend fun decryptEnvelope(envelope: DmEnvelope, currentUserId: Int?): String { + val wrappedMekB64 = envelope.wrappedMekB64 + ?: throw IllegalArgumentException("No wrapped MEK available for decryption") + + // Unwrap the MEK + val mek = unwrapMek(wrappedMekB64, envelope, currentUserId) + + // Decrypt the message using the unwrapped MEK + val plaintext = DmCrypto.decryptEnvelope(envelope.ivB64, envelope.ciphertextB64, mek) + return plaintext.decodeToString() +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt new file mode 100644 index 0000000..533c421 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt @@ -0,0 +1,206 @@ +package ru.fromchat.crypto + +import com.pr0gramm3r101.utils.crypto.Base64 +import com.pr0gramm3r101.utils.settings.secureSettings +import com.pr0gramm3r101.utils.settings.settings +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import ru.fromchat.api.ApiClient +import ru.fromchat.api.PublicKeyResponse +import ru.fromchat.core.Logger +import ru.fromchat.core.config.Config +import ru.fromchat.crypto.backup.BackupCrypto +import ru.fromchat.crypto.backup.decodeBlob +import ru.fromchat.crypto.backup.encodeBlob +import ru.fromchat.crypto.backup.PrivateKeyBundle +import kotlin.concurrent.Volatile + +/** + * Identity keys for the current user + */ +data class IdentityKeys( + val publicKey: ByteArray, + val privateKey: ByteArray +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is IdentityKeys) return false + return publicKey.contentEquals(other.publicKey) && privateKey.contentEquals(other.privateKey) + } + + override fun hashCode(): Int { + var result = publicKey.contentHashCode() + result = 31 * result + privateKey.contentHashCode() + return result + } +} + +/** + * Manages identity keys (X25519 key pair) for the current user + * Handles backup/restore from server and local persistence + */ +object IdentityKeyManager { + @Volatile + private var currentKeys: IdentityKeys? = null + + /** + * Generate a new 32-byte key pair (simplified - for now just random keys) + * TODO: Replace with proper X25519 key generation when available + */ + private fun generateKeyPair(): IdentityKeys { + val privateKey = ByteArray(32).also { randomBytes(it) } + val publicKey = ByteArray(32).also { randomBytes(it) } + return IdentityKeys(publicKey, privateKey) + } + + private fun randomBytes(array: ByteArray) { + // Use platform-specific random + BackupCrypto.randomBytes(array.size).copyInto(array) + } + + /** + * Ensure keys are initialized on login + * Tries to restore from backup, otherwise generates new keys + */ + suspend fun ensureKeysOnLogin(username: String, password: String, token: String): IdentityKeys { + return withContext(Dispatchers.Default) { + try { + // Try to fetch backup from server + val backupJson = fetchBackupBlob(token) + if (backupJson != null) { + // Backup exists: decrypt and restore + val blob = decodeBlob(backupJson) + val bundle = BackupCrypto.decryptBackupWithPassword(password, blob) + val privateKey = bundle.privateKey + + // Fetch public key from server + val serverPubKey = fetchPublicKey(token) + val publicKey = if (serverPubKey != null) { + Base64.decode(serverPubKey) + } else { + // Server doesn't have public key - regenerate pair + val pair = generateKeyPair() + uploadPublicKey(pair.publicKey, token) + val newBlob = BackupCrypto.encryptBackupWithPassword( + password, + PrivateKeyBundle(version = 1, privateKey = pair.privateKey) + ) + uploadBackupBlob(encodeBlob(newBlob), token) + pair.publicKey + } + + val keys = IdentityKeys(publicKey, privateKey) + currentKeys = keys + persistKeys(keys) + return@withContext keys + } + + // No backup: generate new keys and upload + val pair = generateKeyPair() + uploadPublicKey(pair.publicKey, token) + val blob = BackupCrypto.encryptBackupWithPassword( + password, + PrivateKeyBundle(version = 1, privateKey = pair.privateKey) + ) + uploadBackupBlob(encodeBlob(blob), token) + + currentKeys = pair + persistKeys(pair) + return@withContext pair + } catch (e: Exception) { + Logger.e("IdentityKeyManager", "Error ensuring keys on login", e) + throw e + } + } + } + + /** + * Get current keys from memory (non-suspend, returns cached keys only) + */ + fun getCurrentKeys(): IdentityKeys? { + return currentKeys + } + + /** + * Restore keys from local storage (synchronous version for getCurrentKeys) + */ + suspend fun restoreFromLocal(): IdentityKeys? { + return try { + val publicKeyB64 = secureSettings.getString("identity_public_key", "") + val privateKeyB64 = secureSettings.getString("identity_private_key", "") + + if (publicKeyB64.isNotEmpty() && privateKeyB64.isNotEmpty()) { + val keys = IdentityKeys( + publicKey = Base64.decode(publicKeyB64), + privateKey = Base64.decode(privateKeyB64) + ) + currentKeys = keys + keys + } else { + null + } + } catch (e: Exception) { + Logger.e("IdentityKeyManager", "Error restoring keys from storage", e) + null + } + } + + private suspend fun persistKeys(keys: IdentityKeys) { + secureSettings.putString("identity_public_key", Base64.encode(keys.publicKey)) + secureSettings.putString("identity_private_key", Base64.encode(keys.privateKey)) + } + + private suspend fun fetchBackupBlob(token: String): String? { + return try { + val response = ApiClient.http.get("${Config.apiBaseUrl}/crypto/backup") + val backupResponse = response.body() + backupResponse.blob + } catch (e: Exception) { + Logger.d("IdentityKeyManager", "No backup found or error fetching: ${e.message}") + null + } + } + + private suspend fun uploadBackupBlob(blobJson: String, token: String) { + val payload = BackupBlobRequest(blob = blobJson) + ApiClient.http.post("${Config.apiBaseUrl}/crypto/backup") { + setBody(payload) + } + } + + private suspend fun fetchPublicKey(token: String): String? { + return try { + val response: PublicKeyResponse = ApiClient.getOwnPublicKey() + response.publicKey + } catch (e: Exception) { + Logger.d("IdentityKeyManager", "No public key found: ${e.message}") + null + } + } + + private suspend fun uploadPublicKey(publicKey: ByteArray, token: String) { + val payload = UploadPublicKeyRequest(publicKey = Base64.encode(publicKey)) + ApiClient.http.post("${Config.apiBaseUrl}/crypto/public-key") { + setBody(payload) + } + } +} + +@kotlinx.serialization.Serializable +private data class BackupBlobResponse( + val blob: String? +) + +@kotlinx.serialization.Serializable +private data class BackupBlobRequest( + val blob: String +) + +@kotlinx.serialization.Serializable +private data class UploadPublicKeyRequest( + val publicKey: String +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.kt new file mode 100644 index 0000000..5835214 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.kt @@ -0,0 +1,126 @@ +package ru.fromchat.crypto.backup + +import com.pr0gramm3r101.utils.crypto.Base64 +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +/** + * Private key bundle structure matching Web implementation + */ +data class PrivateKeyBundle( + val version: Int = 1, + val privateKey: ByteArray +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PrivateKeyBundle) return false + if (version != other.version) return false + return privateKey.contentEquals(other.privateKey) + } + + override fun hashCode(): Int { + var result = version + result = 31 * result + privateKey.contentHashCode() + return result + } +} + +/** + * Encrypted backup blob structure matching Web implementation + */ +data class EncryptedBackupBlob( + val salt: ByteArray, + val iv: ByteArray, + val ciphertext: ByteArray +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is EncryptedBackupBlob) return false + if (!salt.contentEquals(other.salt)) return false + if (!iv.contentEquals(other.iv)) return false + return ciphertext.contentEquals(other.ciphertext) + } + + override fun hashCode(): Int { + var result = salt.contentHashCode() + result = 31 * result + iv.contentHashCode() + result = 31 * result + ciphertext.contentHashCode() + return result + } +} + +/** + * Platform-specific backup encryption/decryption + */ +expect object BackupCrypto { + /** + * Encrypt a private key bundle with a password + * Uses PBKDF2 (210,000 iterations, SHA-256) to derive KEK, then AES-GCM encryption + */ + suspend fun encryptBackupWithPassword(password: String, bundle: PrivateKeyBundle): EncryptedBackupBlob + + /** + * Decrypt an encrypted backup blob with a password + */ + suspend fun decryptBackupWithPassword(password: String, blob: EncryptedBackupBlob): PrivateKeyBundle + + /** + * Generate random bytes for salt/IV + */ + fun randomBytes(length: Int): ByteArray +} + +/** + * Serialize bundle to bytes: version (1 byte) + length (4 bytes) + privateKey + */ +fun serializeBundle(bundle: PrivateKeyBundle): ByteArray { + val version = bundle.version.toByte() + val len = bundle.privateKey.size + val lenBytes = ByteArray(4) { i -> ((len shr (i * 8)) and 0xFF).toByte() } + return byteArrayOf(version) + lenBytes + bundle.privateKey +} + +/** + * Deserialize bytes to bundle + */ +fun deserializeBundle(data: ByteArray): PrivateKeyBundle { + require(data.size >= 5) { "Bundle data too short" } + val version = data[0].toInt() and 0xFF + val len = (data[1].toInt() and 0xFF) or + ((data[2].toInt() and 0xFF) shl 8) or + ((data[3].toInt() and 0xFF) shl 16) or + ((data[4].toInt() and 0xFF) shl 24) + require(data.size >= 5 + len) { "Bundle data incomplete" } + val privateKey = data.sliceArray(5 until 5 + len) + return PrivateKeyBundle(version, privateKey) +} + +private val json = Json { ignoreUnknownKeys = true } + +/** + * Encode blob to JSON string (base64-encoded fields) + */ +fun encodeBlob(blob: EncryptedBackupBlob): String { + val jsonObj = buildJsonObject { + put("salt", Base64.encode(blob.salt)) + put("iv", Base64.encode(blob.iv)) + put("ciphertext", Base64.encode(blob.ciphertext)) + } + return json.encodeToString(JsonObject.serializer(), jsonObj) +} + +/** + * Decode JSON string to blob + */ +fun decodeBlob(jsonStr: String): EncryptedBackupBlob { + val obj = Json.parseToJsonElement(jsonStr).jsonObject + return EncryptedBackupBlob( + salt = Base64.decode(obj["salt"]!!.jsonPrimitive.content), + iv = Base64.decode(obj["iv"]!!.jsonPrimitive.content), + ciphertext = Base64.decode(obj["ciphertext"]!!.jsonPrimitive.content) + ) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.kt new file mode 100644 index 0000000..8fc085a --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.kt @@ -0,0 +1,31 @@ +package ru.fromchat.crypto.dm + +import com.pr0gramm3r101.utils.crypto.Base64 + +/** + * Platform-specific DM (Direct Message) crypto operations + * Handles MEK unwrapping and envelope decryption + */ +expect object DmCrypto { + /** + * Unwrap a MEK (Message Encryption Key) using a wrapping key + * The wrapped MEK is base64-encoded and contains: nonce (12 bytes) + ciphertext + tag + * Uses AES-GCM decryption + * + * @param wrappedMekB64 Base64-encoded wrapped MEK + * @param wrappingKey 32-byte wrapping key (derived from shared secret via HKDF) + * @return Unwrapped MEK (32 bytes) + */ + suspend fun unwrapMek(wrappedMekB64: String, wrappingKey: ByteArray): ByteArray + + /** + * Decrypt an envelope ciphertext using a MEK + * Uses AES-GCM decryption + * + * @param ivB64 Base64-encoded IV (12 bytes) + * @param ciphertextB64 Base64-encoded ciphertext + tag + * @param mek Message Encryption Key (32 bytes) + * @return Decrypted plaintext + */ + suspend fun decryptEnvelope(ivB64: String, ciphertextB64: String, mek: ByteArray): ByteArray +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt new file mode 100644 index 0000000..43950ec --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt @@ -0,0 +1,28 @@ +package ru.fromchat.crypto.transport + +/** + * Result of client-side transport encryption for DMs. + * + * Matches the Web client's encryptWithTransportKey output: + * - client_public_key_b64: ephemeral X25519 public key, base64 + * - nonce_b64: 24-byte nonce, base64 + * - ciphertext_b64: transport-encrypted ciphertext, base64 + */ +data class TransportCiphertext( + val clientPublicKeyB64: String, + val nonceB64: String, + val ciphertextB64: String +) + +/** + * Platform-specific NaCl box-compatible transport crypto. + * + * Android provides a real implementation; iOS currently provides a stub. + */ +expect object TransportCrypto { + suspend fun encryptWithTransportKey( + plaintext: String, + transportPublicKeyB64: String + ): TransportCiphertext +} + diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 9d55d12..2b352c7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -27,6 +27,7 @@ import ru.fromchat.core.config.Config import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.RegisterScreen import ru.fromchat.ui.chat.PublicChatScreen +import ru.fromchat.ui.debug.DebugApiScreen import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.setup.ServerConfigScreen @@ -169,6 +170,10 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { PublicChatScreen(scrollToMessageId = scrollToMessageId) } + composable("debug") { + DebugApiScreen() + } + composable("about") { AboutScreen() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt index 9cd7082..b111562 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt @@ -43,6 +43,7 @@ import ru.fromchat.Res import ru.fromchat.api.ApiClient import ru.fromchat.api.LoginRequest import ru.fromchat.api.apiRequest +import ru.fromchat.crypto.IdentityKeyManager import ru.fromchat.change_server import ru.fromchat.error_unexpected import ru.fromchat.fill_all_fields @@ -167,8 +168,10 @@ fun LoginScreen( // Derive auth secret before sending (matches frontend implementation) scope.launch { - val derived = deriveAuthSecret(username.trim(), password.trim()) - + val trimmedUsername = username.trim() + val trimmedPassword = password.trim() + val derived = deriveAuthSecret(trimmedUsername, trimmedPassword) + apiRequest( unexpectedError = errorUnexpected, onError = { message, _ -> @@ -178,7 +181,14 @@ fun LoginScreen( onLoginSuccess() } ) { - ApiClient.login(LoginRequest(username.trim(), derived)) + val response = ApiClient.login(LoginRequest(trimmedUsername, derived)) + // Ensure identity keys and backup are initialized after successful login + IdentityKeyManager.ensureKeysOnLogin( + username = trimmedUsername, + password = trimmedPassword, + token = response.token + ) + response } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt new file mode 100644 index 0000000..07f934f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt @@ -0,0 +1,7 @@ +package ru.fromchat.ui.debug + +import androidx.compose.runtime.Composable + +@Composable +expect fun DebugApiScreen() + diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt index c6e7126..833a3cd 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Wallpaper import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip @@ -199,6 +200,20 @@ fun SettingsTab( } ) + ListItem( + headline = "Debug API", + supportingText = "Inspect profile and DM endpoints used by the client.", + onClick = { + navController.navigate("debug") + }, + divider = true, + dividerColor = MaterialTheme.colorScheme.surface, + dividerThickness = 2.dp, + leadingContent = { + Icon(Icons.Filled.BugReport, null) + } + ) + ListItem( headline = stringResource(Res.string.logout), leadingContent = { diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.ios.kt new file mode 100644 index 0000000..128d72f --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.ios.kt @@ -0,0 +1,29 @@ +package ru.fromchat.crypto.backup + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.random.Random + +actual object BackupCrypto { + actual suspend fun encryptBackupWithPassword( + password: String, + bundle: PrivateKeyBundle + ): EncryptedBackupBlob = withContext(Dispatchers.Default) { + // iOS backup encryption is not implemented yet; fail fast if called. + error("Backup encryption is not yet implemented on iOS.") + } + + actual suspend fun decryptBackupWithPassword( + password: String, + blob: EncryptedBackupBlob + ): PrivateKeyBundle = withContext(Dispatchers.Default) { + // iOS backup decryption is not implemented yet; fail fast if called. + error("Backup decryption is not yet implemented on iOS.") + } + + actual fun randomBytes(length: Int): ByteArray { + val bytes = ByteArray(length) + Random.Default.nextBytes(bytes) + return bytes + } +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt new file mode 100644 index 0000000..a8193b9 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt @@ -0,0 +1,79 @@ +package ru.fromchat.crypto.dm + +import com.pr0gramm3r101.utils.crypto.Base64 +import com.pr0gramm3r101.utils.crypto.Hmac +import com.pr0gramm3r101.utils.crypto.PasswordHash +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.value +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import platform.CoreCrypto.CCCryptorCreateWithMode +import platform.CoreCrypto.CCCryptorRelease +import platform.CoreCrypto.CCCryptorUpdate +import platform.CoreCrypto.CCCryptorFinal +import platform.CoreCrypto.kCCAlgorithmAES +import platform.CoreCrypto.kCCKeySizeAES256 +import platform.CoreCrypto.kCCDecrypt +import platform.CoreCrypto.kCCSuccess + +// GCM mode constant (value 11) - CommonCrypto GCM support via cinterop is limited +private const val kCCModeGCM = 11u + +actual object DmCrypto { + private const val AES_KEY_SIZE = 32 + private const val GCM_IV_SIZE = 12 + private const val GCM_TAG_SIZE = 16 + + @OptIn(ExperimentalForeignApi::class) + actual suspend fun unwrapMek(wrappedMekB64: String, wrappingKey: ByteArray): ByteArray = + withContext(Dispatchers.Default) { + require(wrappingKey.size == AES_KEY_SIZE) { "Wrapping key must be 32 bytes" } + + val wrapped = Base64.decode(wrappedMekB64) + require(wrapped.size >= GCM_IV_SIZE + GCM_TAG_SIZE) { "Wrapped MEK too short" } + + // Extract IV and ciphertext+tag + val iv = wrapped.sliceArray(0 until GCM_IV_SIZE) + val ciphertext = wrapped.sliceArray(GCM_IV_SIZE until wrapped.size) + + // Decrypt using AES-GCM + aesGcmDecrypt(wrappingKey, iv, ciphertext) + } + + @OptIn(ExperimentalForeignApi::class) + actual suspend fun decryptEnvelope( + ivB64: String, + ciphertextB64: String, + mek: ByteArray + ): ByteArray = withContext(Dispatchers.Default) { + require(mek.size == AES_KEY_SIZE) { "MEK must be 32 bytes" } + + val iv = Base64.decode(ivB64) + val ciphertext = Base64.decode(ciphertextB64) + + require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" } + require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" } + + // Decrypt using AES-GCM + aesGcmDecrypt(mek, iv, ciphertext) + } + + @OptIn(ExperimentalForeignApi::class) + private fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): ByteArray { + require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes for GCM" } + require(key.size == AES_KEY_SIZE) { "Key must be 32 bytes (256 bits)" } + require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" } + + // TODO: Implement proper AES-GCM using CommonCrypto or CryptoKit + // See BackupCrypto.ios.kt for implementation notes. + // For now, delegate to BackupCrypto's implementation when available. + + error("AES-GCM decryption not yet implemented for iOS. Use CryptoKit via Swift interop or krypto library.") + } +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt new file mode 100644 index 0000000..4974747 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt @@ -0,0 +1,14 @@ +package ru.fromchat.crypto.transport + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +actual object TransportCrypto { + actual suspend fun encryptWithTransportKey( + plaintext: String, + transportPublicKeyB64: String + ): TransportCiphertext = withContext(Dispatchers.Default) { + error("TransportCrypto.encryptWithTransportKey is not yet implemented on iOS.") + } +} + diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.ios.kt new file mode 100644 index 0000000..122fd2c --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.ios.kt @@ -0,0 +1,9 @@ +package ru.fromchat.ui.debug + +import androidx.compose.runtime.Composable + +@Composable +actual fun DebugApiScreen() { + // iOS debug UI not implemented; Android-only for now. +} + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c5dfeb1..d51ee92 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ security-crypto = "1.1.0" ktor = "3.3.3" slf4j = "1.7.36" kotlinxDatetime = "0.7.1" +krypto = "4.0.10" lifecycleRuntimeKtx = "2.10.0" composeBom = "2026.01.00" composeMaterialIconsExtended = "1.7.3" @@ -89,6 +90,7 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-mu compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeComponents" } compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" } compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" } +krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }