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 {
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
// NaCl box implementation for transport encryption (Android/JVM only)
|
||||
implementation("org.purejava:tweetnacl-java:1.1.3")
|
||||
}
|
||||
|
||||
commonMain.dependencies {
|
||||
@@ -73,6 +75,7 @@ kotlin {
|
||||
implementation(libs.coil.network.ktor3)
|
||||
|
||||
implementation(project(":utils:shared"))
|
||||
implementation(libs.krypto)
|
||||
}
|
||||
|
||||
iosMain.dependencies {
|
||||
|
||||
@@ -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="light">Light</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 -->
|
||||
<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.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.request.put
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
@@ -30,6 +31,9 @@ import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import com.pr0gramm3r101.utils.crypto.Base64
|
||||
import ru.fromchat.crypto.IdentityKeyManager
|
||||
import ru.fromchat.crypto.transport.TransportCrypto
|
||||
|
||||
/**
|
||||
* Creates a platform-specific HTTP client that supports WebSockets
|
||||
@@ -158,6 +162,160 @@ object ApiClient {
|
||||
}
|
||||
.body<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
|
||||
suspend fun validateToken(): Boolean {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ru.fromchat.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@@ -34,6 +35,38 @@ data class User(
|
||||
val profile_picture: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserProfile(
|
||||
val id: Int,
|
||||
val username: String,
|
||||
@SerialName("display_name") val displayName: String? = null,
|
||||
@SerialName("profile_picture") val profilePicture: String? = null,
|
||||
val bio: String? = null,
|
||||
val online: Boolean = false,
|
||||
@SerialName("last_seen") val lastSeen: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
val verified: Boolean? = null,
|
||||
val suspended: Boolean? = null,
|
||||
@SerialName("suspension_reason") val suspensionReason: String? = null,
|
||||
val deleted: Boolean? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ProfileDialogData(
|
||||
@SerialName("user_id") val userId: Int? = null,
|
||||
val username: String? = null,
|
||||
@SerialName("display_name") val displayName: String? = null,
|
||||
@SerialName("profile_picture") val profilePicture: String? = null,
|
||||
val bio: String? = null,
|
||||
@SerialName("member_since") val memberSince: String? = null,
|
||||
val online: Boolean? = null,
|
||||
@SerialName("is_own_profile") val isOwnProfile: Boolean = false,
|
||||
val verified: Boolean? = null,
|
||||
val suspended: Boolean? = null,
|
||||
@SerialName("suspension_reason") val suspensionReason: String? = null,
|
||||
val deleted: Boolean? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginResponse(
|
||||
val user: User,
|
||||
@@ -122,6 +155,87 @@ data class TypingData(
|
||||
val username: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DmFile(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val path: String,
|
||||
@SerialName("dm_envelope_id") val dmEnvelopeId: Int? = null,
|
||||
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
|
||||
@SerialName("nonce_b64") val nonceB64: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DmEnvelope(
|
||||
val id: Int,
|
||||
val senderId: Int,
|
||||
val recipientId: Int,
|
||||
@SerialName("iv_b64") val ivB64: String,
|
||||
@SerialName("ciphertext_b64") val ciphertextB64: String,
|
||||
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
|
||||
val timestamp: String,
|
||||
val replyToId: Int? = null,
|
||||
val files: List<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
|
||||
@Serializable
|
||||
data class UpdateItem(
|
||||
@@ -153,4 +267,14 @@ data class WebSocketEditMessageRequest(
|
||||
@Serializable
|
||||
data class WebSocketDeleteMessageRequest(
|
||||
val message_id: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupBlobResponse(
|
||||
val blob: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupBlobRequest(
|
||||
val blob: String
|
||||
)
|
||||
@@ -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.RegisterScreen
|
||||
import ru.fromchat.ui.chat.PublicChatScreen
|
||||
import ru.fromchat.ui.debug.DebugApiScreen
|
||||
import ru.fromchat.ui.main.MainScreen
|
||||
import ru.fromchat.ui.setup.ServerConfigScreen
|
||||
|
||||
@@ -169,6 +170,10 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
||||
PublicChatScreen(scrollToMessageId = scrollToMessageId)
|
||||
}
|
||||
|
||||
composable("debug") {
|
||||
DebugApiScreen()
|
||||
}
|
||||
|
||||
composable("about") {
|
||||
AboutScreen()
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.LoginRequest
|
||||
import ru.fromchat.api.apiRequest
|
||||
import ru.fromchat.crypto.IdentityKeyManager
|
||||
import ru.fromchat.change_server
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.fill_all_fields
|
||||
@@ -167,8 +168,10 @@ fun LoginScreen(
|
||||
|
||||
// Derive auth secret before sending (matches frontend implementation)
|
||||
scope.launch {
|
||||
val derived = deriveAuthSecret(username.trim(), password.trim())
|
||||
|
||||
val trimmedUsername = username.trim()
|
||||
val trimmedPassword = password.trim()
|
||||
val derived = deriveAuthSecret(trimmedUsername, trimmedPassword)
|
||||
|
||||
apiRequest(
|
||||
unexpectedError = errorUnexpected,
|
||||
onError = { message, _ ->
|
||||
@@ -178,7 +181,14 @@ fun LoginScreen(
|
||||
onLoginSuccess()
|
||||
}
|
||||
) {
|
||||
ApiClient.login(LoginRequest(username.trim(), derived))
|
||||
val response = ApiClient.login(LoginRequest(trimmedUsername, derived))
|
||||
// Ensure identity keys and backup are initialized after successful login
|
||||
IdentityKeyManager.ensureKeysOnLogin(
|
||||
username = trimmedUsername,
|
||||
password = trimmedPassword,
|
||||
token = response.token
|
||||
)
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.Settings
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Wallpaper
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
@@ -199,6 +200,20 @@ fun SettingsTab(
|
||||
}
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = "Debug API",
|
||||
supportingText = "Inspect profile and DM endpoints used by the client.",
|
||||
onClick = {
|
||||
navController.navigate("debug")
|
||||
},
|
||||
divider = true,
|
||||
dividerColor = MaterialTheme.colorScheme.surface,
|
||||
dividerThickness = 2.dp,
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.BugReport, null)
|
||||
}
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.logout),
|
||||
leadingContent = {
|
||||
|
||||
@@ -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"
|
||||
slf4j = "1.7.36"
|
||||
kotlinxDatetime = "0.7.1"
|
||||
krypto = "4.0.10"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
composeBom = "2026.01.00"
|
||||
composeMaterialIconsExtended = "1.7.3"
|
||||
@@ -89,6 +90,7 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-mu
|
||||
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeComponents" }
|
||||
compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" }
|
||||
compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" }
|
||||
krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user