Implement encryption protocol

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-01 22:36:20 +03:00
Unverified
parent b1d2e5bc26
commit c5573e9f25
22 changed files with 1354 additions and 3 deletions
+3
View File
@@ -37,6 +37,8 @@ kotlin {
androidMain.dependencies { androidMain.dependencies {
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
// NaCl box implementation for transport encryption (Android/JVM only)
implementation("org.purejava:tweetnacl-java:1.1.3")
} }
commonMain.dependencies { commonMain.dependencies {
@@ -73,6 +75,7 @@ kotlin {
implementation(libs.coil.network.ktor3) implementation(libs.coil.network.ktor3)
implementation(project(":utils:shared")) implementation(project(":utils:shared"))
implementation(libs.krypto)
} }
iosMain.dependencies { iosMain.dependencies {
@@ -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<ByteArray, ByteArray> =
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)
}
}
@@ -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)
}
}
@@ -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)
)
}
}
@@ -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<String?>(null) }
var conversationsResult by rememberSaveable { mutableStateOf<String?>(null) }
var historyResult by rememberSaveable { mutableStateOf<String?>(null) }
var historyUserId by rememberSaveable { mutableStateOf("0") }
var decryptedMessage by rememberSaveable { mutableStateOf<String?>(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()
)
}
}
}
@@ -56,6 +56,18 @@
<string name="as_system">As system</string> <string name="as_system">As system</string>
<string name="light">Light</string> <string name="light">Light</string>
<string name="dark">Dark</string> <string name="dark">Dark</string>
<string name="debug_tools">Debug API</string>
<string name="debug_tools_d">Inspect profile and DM endpoints used by the client.</string>
<string name="debug_status_placeholder">Status will appear here</string>
<string name="debug_profile_loaded">Profile loaded</string>
<string name="debug_load_profile">Load own profile</string>
<string name="debug_not_loaded">No data loaded yet</string>
<string name="debug_conversations_loaded">Conversations loaded</string>
<string name="debug_load_conversations">Load DM conversations</string>
<string name="debug_history_user_id_label">History user ID</string>
<string name="debug_invalid_user_id">Enter a valid user ID</string>
<string name="debug_history_loaded">DM history loaded</string>
<string name="debug_load_history">Load DM history</string>
<!-- Error Messages --> <!-- Error Messages -->
<string name="error_unexpected">Unexpected error</string> <string name="error_unexpected">Unexpected error</string>
@@ -19,6 +19,7 @@ import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody 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.http.contentType import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
@@ -30,6 +31,9 @@ import ru.fromchat.core.config.Config
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.milliseconds 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 * Creates a platform-specific HTTP client that supports WebSockets
@@ -158,6 +162,160 @@ object ApiClient {
} }
.body<MessagesResponse>() .body<MessagesResponse>()
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<DmConversation> =
http
.get("${Config.apiBaseUrl}/dm/conversations") {
contentType(ContentType.Application.Json)
}
.body<DmConversationsResponse>()
.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<BackupBlobResponse>()
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 // Validate token by fetching user profile
suspend fun validateToken(): Boolean { suspend fun validateToken(): Boolean {
try { try {
@@ -1,5 +1,6 @@
package ru.fromchat.api package ru.fromchat.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@@ -34,6 +35,38 @@ data class User(
val profile_picture: String? = null 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 @Serializable
data class LoginResponse( data class LoginResponse(
val user: User, val user: User,
@@ -122,6 +155,87 @@ data class TypingData(
val username: String 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<DmFile>? = null
)
@Serializable
data class DmConversation(
val user: User,
val lastMessage: DmEnvelope,
val unreadCount: Int
)
@Serializable
data class DmConversationsResponse(
val conversations: List<DmConversation> = emptyList()
)
@Serializable
data class DmHistoryResponse(
val messages: List<DmEnvelope> = 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<SendDmFile> = 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 // Batched updates message
@Serializable @Serializable
data class UpdateItem( data class UpdateItem(
@@ -153,4 +267,14 @@ data class WebSocketEditMessageRequest(
@Serializable @Serializable
data class WebSocketDeleteMessageRequest( data class WebSocketDeleteMessageRequest(
val message_id: Int val message_id: Int
)
@Serializable
data class BackupBlobResponse(
val blob: String?
)
@Serializable
data class BackupBlobRequest(
val blob: String
) )
@@ -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()
}
@@ -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<BackupBlobResponse>()
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
)
@@ -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)
)
}
@@ -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
}
@@ -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
}
@@ -27,6 +27,7 @@ import ru.fromchat.core.config.Config
import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen import ru.fromchat.ui.auth.RegisterScreen
import ru.fromchat.ui.chat.PublicChatScreen import ru.fromchat.ui.chat.PublicChatScreen
import ru.fromchat.ui.debug.DebugApiScreen
import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.setup.ServerConfigScreen import ru.fromchat.ui.setup.ServerConfigScreen
@@ -169,6 +170,10 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
PublicChatScreen(scrollToMessageId = scrollToMessageId) PublicChatScreen(scrollToMessageId = scrollToMessageId)
} }
composable("debug") {
DebugApiScreen()
}
composable("about") { composable("about") {
AboutScreen() AboutScreen()
} }
@@ -43,6 +43,7 @@ import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.LoginRequest import ru.fromchat.api.LoginRequest
import ru.fromchat.api.apiRequest import ru.fromchat.api.apiRequest
import ru.fromchat.crypto.IdentityKeyManager
import ru.fromchat.change_server import ru.fromchat.change_server
import ru.fromchat.error_unexpected import ru.fromchat.error_unexpected
import ru.fromchat.fill_all_fields import ru.fromchat.fill_all_fields
@@ -167,8 +168,10 @@ fun LoginScreen(
// Derive auth secret before sending (matches frontend implementation) // Derive auth secret before sending (matches frontend implementation)
scope.launch { scope.launch {
val derived = deriveAuthSecret(username.trim(), password.trim()) val trimmedUsername = username.trim()
val trimmedPassword = password.trim()
val derived = deriveAuthSecret(trimmedUsername, trimmedPassword)
apiRequest( apiRequest(
unexpectedError = errorUnexpected, unexpectedError = errorUnexpected,
onError = { message, _ -> onError = { message, _ ->
@@ -178,7 +181,14 @@ fun LoginScreen(
onLoginSuccess() 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
} }
} }
} }
@@ -0,0 +1,7 @@
package ru.fromchat.ui.debug
import androidx.compose.runtime.Composable
@Composable
expect fun DebugApiScreen()
@@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Wallpaper import androidx.compose.material.icons.filled.Wallpaper
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip 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( ListItem(
headline = stringResource(Res.string.logout), headline = stringResource(Res.string.logout),
leadingContent = { leadingContent = {
@@ -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
}
}
@@ -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.")
}
}
@@ -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.")
}
}
@@ -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.
}
+2
View File
@@ -29,6 +29,7 @@ security-crypto = "1.1.0"
ktor = "3.3.3" ktor = "3.3.3"
slf4j = "1.7.36" slf4j = "1.7.36"
kotlinxDatetime = "0.7.1" kotlinxDatetime = "0.7.1"
krypto = "4.0.10"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
composeBom = "2026.01.00" composeBom = "2026.01.00"
composeMaterialIconsExtended = "1.7.3" 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-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-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" } compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" }
krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }