mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement encryption protocol
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+41
@@ -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.
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user