diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e16f719cb0..5b386017d5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,7 @@ on: branches: - master - stable + - sqlcipher tags: - "v*" pull_request: @@ -67,9 +68,9 @@ jobs: - name: Setup Stack uses: haskell/actions/setup@v1 with: - ghc-version: '8.10.7' + ghc-version: "8.10.7" enable-stack: true - stack-version: 'latest' + stack-version: "latest" - name: Cache dependencies uses: actions/cache@v2 @@ -81,7 +82,7 @@ jobs: - name: Unix build id: unix_build - if: matrix.os != 'windows-latest' + if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-18.04' shell: bash run: | stack build --test @@ -98,6 +99,27 @@ jobs: # Unix / + # / Mac + + - name: Mac build + id: mac_build + if: matrix.os == 'macos-latest' + shell: bash + run: | + stack build --test --extra-include-dirs=/usr/local/opt/openssl@1.1/include --extra-lib-dirs=/usr/local/opt/openssl@1.1/lib + echo "::set-output name=local_install_root::$(stack path --local-install-root)" + + - name: Unix upload binary to release + if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest' + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ${{ steps.unix_build.outputs.local_install_root }}/bin/simplex-chat + asset_name: ${{ matrix.asset_name }} + tag: ${{ github.ref }} + + # Mac / + # / Windows # * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753 diff --git a/apps/android/.gitignore b/apps/android/.gitignore index e4dd4a5169..644d967fb1 100644 --- a/apps/android/.gitignore +++ b/apps/android/.gitignore @@ -16,3 +16,4 @@ .externalNativeBuild .cxx local.properties +app/src/main/cpp/libs/ diff --git a/apps/android/app/src/main/cpp/CMakeLists.txt b/apps/android/app/src/main/cpp/CMakeLists.txt index 28e2a25b13..e97f01708f 100644 --- a/apps/android/app/src/main/cpp/CMakeLists.txt +++ b/apps/android/app/src/main/cpp/CMakeLists.txt @@ -53,6 +53,9 @@ add_library( support SHARED IMPORTED ) set_target_properties( support PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so) +add_library( crypto SHARED IMPORTED ) +set_target_properties( crypto PROPERTIES IMPORTED_LOCATION + ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libcrypto.so) # Specifies libraries CMake should link to your target library. You # can link multiple libraries, such as libraries you define in this @@ -61,7 +64,7 @@ set_target_properties( support PROPERTIES IMPORTED_LOCATION target_link_libraries( # Specifies the target library. app-lib - simplex support + simplex support crypto # Links the target library to the log library # included in the NDK. diff --git a/apps/android/app/src/main/cpp/simplex-api.c b/apps/android/app/src/main/cpp/simplex-api.c index 5be1d1fadd..10c9efd6fe 100644 --- a/apps/android/app/src/main/cpp/simplex-api.c +++ b/apps/android/app/src/main/cpp/simplex-api.c @@ -24,20 +24,42 @@ Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass // from simplex-chat typedef void* chat_ctrl; -extern chat_ctrl chat_init(const char *path); +extern char *chat_migrate_db(const char *path, const char *key); +extern chat_ctrl chat_init_key(const char *path, const char *key); +extern chat_ctrl chat_init(const char *path); // deprecated extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd); -extern char *chat_recv_msg(chat_ctrl ctrl); +extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); -JNIEXPORT jlong JNICALL -Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) { - const char *_data = (*env)->GetStringUTFChars(env, datadir, JNI_FALSE); - jlong res = (jlong)chat_init(_data); - (*env)->ReleaseStringUTFChars(env, datadir, _data); +JNIEXPORT jstring JNICALL +Java_chat_simplex_app_SimplexAppKt_chatMigrateDB(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) { + const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE); + const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_migrate_db(_dbPath, _dbKey)); + (*env)->ReleaseStringUTFChars(env, dbPath, _dbPath); + (*env)->ReleaseStringUTFChars(env, dbKey, _dbKey); return res; } +JNIEXPORT jlong JNICALL +Java_chat_simplex_app_SimplexAppKt_chatInitKey(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) { + const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE); + const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE); + jlong ctrl = (jlong)chat_init_key(_dbPath, _dbKey); + (*env)->ReleaseStringUTFChars(env, dbPath, _dbPath); + (*env)->ReleaseStringUTFChars(env, dbKey, _dbKey); + return ctrl; +} + +JNIEXPORT jlong JNICALL +Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring dbPath) { + const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE); + jlong ctrl = (jlong)chat_init(_dbPath); + (*env)->ReleaseStringUTFChars(env, dbPath, _dbPath); + return ctrl; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_app_SimplexAppKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) { const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE); diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index de7b76f120..7d0e7ed911 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -32,6 +32,7 @@ import chat.simplex.app.views.call.IncomingCallAlertView import chat.simplex.app.views.chat.ChatView import chat.simplex.app.views.chatlist.ChatListView import chat.simplex.app.views.chatlist.openChat +import chat.simplex.app.views.database.DatabaseErrorView import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.connectViaUri import chat.simplex.app.views.newchat.withUriAction @@ -56,7 +57,6 @@ class MainActivity: FragmentActivity() { } } private val vm by viewModels() - private val chatController by lazy { (application as SimplexApp).chatController } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -251,6 +251,13 @@ fun MainPage( } chatsAccessAuthorized = userAuthorized.value == true } + var showChatDatabaseError by rememberSaveable { + mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null) + } + LaunchedEffect(chatModel.chatDbStatus.value) { + showChatDatabaseError = chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null + } + var showAdvertiseLAAlert by remember { mutableStateOf(false) } LaunchedEffect(showAdvertiseLAAlert) { if ( @@ -296,6 +303,11 @@ fun MainPage( val onboarding = chatModel.onboardingStage.value val userCreated = chatModel.userCreated.value when { + showChatDatabaseError -> { + chatModel.chatDbStatus.value?.let { + DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs) + } + } onboarding == null || userCreated == null -> SplashView() !chatsAccessAuthorized -> { if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index c2c777c80b..2cd3094951 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -26,21 +26,46 @@ external fun pipeStdOutToSocket(socketName: String) : Int // SimpleX API typealias ChatCtrl = Long -external fun chatInit(path: String): ChatCtrl +external fun chatMigrateDB(dbPath: String, dbKey: String): String +external fun chatInitKey(dbPath: String, dbKey: String): ChatCtrl +external fun chatInit(dbPath: String): ChatCtrl external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String external fun chatRecvMsg(ctrl: ChatCtrl): String external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String external fun chatParseMarkdown(str: String): String class SimplexApp: Application(), LifecycleEventObserver { - val chatController: ChatController by lazy { - val ctrl = chatInit(getFilesDirectory(applicationContext)) - ChatController(ctrl, ntfManager, applicationContext, appPreferences) + lateinit var chatController: ChatController + + fun initChatController(useKey: String? = null, startChat: Boolean = true) { + val dbKey = useKey ?: DatabaseUtils.getDatabaseKey() ?: "" + val res = DatabaseUtils.migrateChatDatabase(dbKey) + val ctrl = if (res.second is DBMigrationResult.OK) { + chatInitKey(getFilesDirectory(applicationContext), dbKey) + } else null + if (::chatController.isInitialized) { + chatController.ctrl = ctrl + } else { + chatController = ChatController(ctrl, ntfManager, applicationContext, appPreferences) + } + chatModel.chatDbEncrypted.value = res.first + chatModel.chatDbStatus.value = res.second + if (res.second != DBMigrationResult.OK) { + Log.d(TAG, "Unable to migrate successfully: ${res.second}") + } else if (startChat) { + withApi { + val user = chatController.apiGetActiveUser() + if (user == null) { + chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo + } else { + chatController.startChat(user) + } + } + } } - val chatModel: ChatModel by lazy { - chatController.chatModel - } + val chatModel: ChatModel + get() = chatController.chatModel private val ntfManager: NtfManager by lazy { NtfManager(applicationContext, appPreferences) @@ -53,15 +78,8 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun onCreate() { super.onCreate() context = this + initChatController() ProcessLifecycleOwner.get().lifecycle.addObserver(this) - withApi { - val user = chatController.apiGetActiveUser() - if (user == null) { - chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo - } else { - chatController.startChat(user) - } - } } override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt index c1dc9a7af3..b567e0d8c7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt @@ -9,7 +9,7 @@ import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.work.* -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.helpers.* import chat.simplex.app.views.onboarding.OnboardingStage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -24,7 +24,6 @@ class SimplexService: Service() { private var isStartingService = false private var notificationManager: NotificationManager? = null private var serviceNotification: Notification? = null - private val chatController by lazy { (application as SimplexApp).chatController } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.d(TAG, "onStartCommand startId: $startId") @@ -67,19 +66,21 @@ class SimplexService: Service() { val self = this isStartingService = true withApi { + val chatController = (application as SimplexApp).chatController try { - val user = chatController.apiGetActiveUser() - if (user == null) { - chatController.chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo - } else { - Log.w(TAG, "Starting foreground service") - chatController.startChat(user) - isServiceStarted = true - saveServiceState(self, ServiceState.STARTED) - wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run { - newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { - acquire() - } + Log.w(TAG, "Starting foreground service") + val chatDbStatus = chatController.chatModel.chatDbStatus.value + if (chatDbStatus != DBMigrationResult.OK) { + Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus") + showPassphraseNotification(chatDbStatus) + stopService() + return@withApi + } + isServiceStarted = true + saveServiceState(self, ServiceState.STARTED) + wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run { + newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { + acquire() } } } finally { @@ -227,6 +228,8 @@ class SimplexService: Service() { const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L const val SERVICE_START_WORKER_WORK_NAME_PERIODIC = "SimplexAutoRestartWorkerPeriodic" // Do not change! + private const val PASSPHRASE_NOTIFICATION_ID = 1535 + private const val WAKE_LOCK_TAG = "SimplexService::lock" private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_SERVICE_PREFS" private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE" @@ -271,6 +274,41 @@ class SimplexService: Service() { return ServiceState.valueOf(value!!) } + fun showPassphraseNotification(chatDbStatus: DBMigrationResult?) { + val pendingIntent: PendingIntent = Intent(SimplexApp.context, MainActivity::class.java).let { notificationIntent -> + PendingIntent.getActivity(SimplexApp.context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE) + } + + val title = when(chatDbStatus) { + is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_title) + is DBMigrationResult.OK -> return + else -> generalGetString(R.string.database_initialization_error_title) + } + + val description = when(chatDbStatus) { + is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_desc) + is DBMigrationResult.OK -> return + else -> generalGetString(R.string.database_initialization_error_desc) + } + + val builder = NotificationCompat.Builder(SimplexApp.context, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ntf_service_icon) + .setColor(0x88FFFF) + .setContentTitle(title) + .setContentText(description) + .setContentIntent(pendingIntent) + .setSilent(true) + .setShowWhen(false) + + val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(PASSPHRASE_NOTIFICATION_ID, builder.build()) + } + + fun cancelPassphraseNotification() { + val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(PASSPHRASE_NOTIFICATION_ID) + } + private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) } } \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 60b5961c2c..c75149437c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.text.style.TextDecoration import chat.simplex.app.R import chat.simplex.app.ui.theme.* import chat.simplex.app.views.call.* +import chat.simplex.app.views.helpers.DBMigrationResult import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.onboarding.OnboardingStage import chat.simplex.app.views.usersettings.NotificationPreviewMode @@ -27,6 +28,8 @@ class ChatModel(val controller: ChatController) { val userCreated = mutableStateOf(null) val chatRunning = mutableStateOf(null) val chatDbChanged = mutableStateOf(false) + val chatDbEncrypted = mutableStateOf(false) + val chatDbStatus = mutableStateOf(null) val chats = mutableStateListOf() // current chat diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index ca0aeb4732..070523353a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -34,13 +34,13 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference private val msgNtfTimeoutMs = 30000L init { - manager.createNotificationChannel(NotificationChannel(MessageChannel, "SimpleX Chat messages", NotificationManager.IMPORTANCE_HIGH)) - manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, "SimpleX Chat calls (lock screen)", NotificationManager.IMPORTANCE_HIGH)) + manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH)) + manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, generalGetString(R.string.ntf_channel_calls_lockscreen), NotificationManager.IMPORTANCE_HIGH)) manager.createNotificationChannel(callNotificationChannel()) } private fun callNotificationChannel(): NotificationChannel { - val callChannel = NotificationChannel(CallChannel, "SimpleX Chat calls", NotificationManager.IMPORTANCE_HIGH) + val callChannel = NotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls), NotificationManager.IMPORTANCE_HIGH) val attrs = AudioAttributes.Builder() .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .setUsage(AudioAttributes.USAGE_NOTIFICATION) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 10321ac912..d6996d8a26 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -106,6 +106,11 @@ class AppPreferences(val context: Context) { val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt) val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false) + val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true) + val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false) + val encryptedDBPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE, null) + val initializationVectorDBPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE, null) + val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name) val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb()) @@ -180,6 +185,10 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_NETWORK_TCP_KEEP_INTVL = "NetworkTCPKeepIntvl" private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt" private const val SHARED_PREFS_INCOGNITO = "Incognito" + private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase" + private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase" + private const val SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE = "EncryptedDBPassphrase" + private const val SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE = "InitializationVectorDBPassphrase" private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme" private const val SHARED_PREFS_PRIMARY_COLOR = "PrimaryColor" } @@ -187,7 +196,7 @@ class AppPreferences(val context: Context) { private const val MESSAGE_TIMEOUT: Int = 15_000_000 -open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context, val appPrefs: AppPreferences) { +open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val appContext: Context, val appPrefs: AppPreferences) { val chatModel = ChatModel(this) private var receiverStarted = false var lastMsgReceivedTimestamp: Long = System.currentTimeMillis() @@ -242,10 +251,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } suspend fun sendCmd(cmd: CC): CR { + val ctrl = ctrl ?: throw Exception("Controller is not initialized") + return withContext(Dispatchers.IO) { val c = cmd.cmdString if (cmd !is CC.ApiParseMarkdown) { - chatModel.terminalItems.add(TerminalItem.cmd(cmd)) + chatModel.terminalItems.add(TerminalItem.cmd(cmd.obfuscated)) Log.d(TAG, "sendCmd: ${cmd.cmdType}") } val json = chatSendCmd(ctrl, c) @@ -261,7 +272,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } - private suspend fun recvMsg(): CR? { + private suspend fun recvMsg(ctrl: ChatCtrl): CR? { return withContext(Dispatchers.IO) { val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) if (json == "") { @@ -276,7 +287,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } private suspend fun recvMspLoop() { - val msg = recvMsg() + val msg = recvMsg(ctrl ?: return) if (msg != null) processReceivedMsg(msg) recvMspLoop() } @@ -343,6 +354,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager throw Error("failed to delete storage: ${r.responseType} ${r.details}") } + suspend fun apiStorageEncryption(currentKey: String = "", newKey: String = ""): CR.ChatCmdError? { + val r = sendCmd(CC.ApiStorageEncryption(DBEncryptionConfig(currentKey, newKey))) + if (r is CR.CmdOk) return null + else if (r is CR.ChatCmdError) return r + throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}") + } + private suspend fun apiGetChats(): List { val r = sendCmd(CC.ApiGetChats()) if (r is CR.ApiChats ) return r.chats @@ -1197,6 +1215,7 @@ sealed class CC { class ApiExportArchive(val config: ArchiveConfig): CC() class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() + class ApiStorageEncryption(val config: DBEncryptionConfig): CC() class ApiGetChats: CC() class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC() @@ -1251,6 +1270,7 @@ sealed class CC { is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" + is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}" is ApiGetChats -> "/_get chats pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" @@ -1305,6 +1325,7 @@ sealed class CC { is ApiExportArchive -> "apiExportArchive" is ApiImportArchive -> "apiImportArchive" is ApiDeleteStorage -> "apiDeleteStorage" + is ApiStorageEncryption -> "apiStorageEncryption" is ApiGetChats -> "apiGetChats" is ApiGetChat -> "apiGetChat" is ApiSendMessage -> "apiSendMessage" @@ -1350,6 +1371,14 @@ sealed class CC { class ItemRange(val from: Long, val to: Long) + val obfuscated: CC + get() = when (this) { + is ApiStorageEncryption -> ApiStorageEncryption(DBEncryptionConfig(obfuscate(config.currentKey), obfuscate(config.newKey))) + else -> this + } + + private fun obfuscate(s: String): String = if (s.isEmpty()) "" else "***" + companion object { fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}" @@ -1381,6 +1410,9 @@ class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgCon @Serializable class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null) +@Serializable +class DBEncryptionConfig(val currentKey: String, val newKey: String) + @Serializable data class NetCfg( val socksProxy: String? = null, @@ -1785,10 +1817,12 @@ sealed class ChatError { is ChatErrorChat -> "chat ${errorType.string}" is ChatErrorAgent -> "agent ${agentError.string}" is ChatErrorStore -> "store ${storeError.string}" + is ChatErrorDatabase -> "database ${databaseError.string}" } @Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError() @Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError() @Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError() + @Serializable @SerialName("errorDatabase") class ChatErrorDatabase(val databaseError: DatabaseError): ChatError() } @Serializable @@ -1813,6 +1847,28 @@ sealed class StoreError { @Serializable @SerialName("groupNotFound") class GroupNotFound: StoreError() } +@Serializable +sealed class DatabaseError { + val string: String get() = when (this) { + is ErrorEncrypted -> "errorEncrypted" + is ErrorPlaintext -> "errorPlaintext" + is ErrorNoFile -> "errorPlaintext" + is ErrorExport -> "errorNoFile" + is ErrorOpen -> "errorExport" + } + @Serializable @SerialName("errorEncrypted") object ErrorEncrypted: DatabaseError() + @Serializable @SerialName("errorPlaintext") object ErrorPlaintext: DatabaseError() + @Serializable @SerialName("errorNoFile") class ErrorNoFile(val dbFile: String): DatabaseError() + @Serializable @SerialName("errorExport") class ErrorExport(val sqliteError: SQLiteError): DatabaseError() + @Serializable @SerialName("errorOpen") class ErrorOpen(val sqliteError: SQLiteError): DatabaseError() +} + +@Serializable +sealed class SQLiteError { + @Serializable @SerialName("errorNotADatabase") object ErrorNotADatabase: SQLiteError() + @Serializable @SerialName("error") class Error(val error: String): SQLiteError() +} + @Serializable sealed class AgentErrorType { val string: String get() = when (this) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt index 5b3e0b0ea6..8ac28570f5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt @@ -24,5 +24,6 @@ val GroupDark = Color(80, 80, 80, 60) val IncomingCallLight = Color(239, 237, 236, 255) val IncomingCallDark = Color(34, 30, 29, 255) val WarningOrange = Color(255, 127, 0, 255) +val WarningYellow = Color(255, 192, 0, 255) val FileLight = Color(183, 190, 199, 255) val FileDark = Color(101, 101, 106, 255) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index c0ca2ae479..f55b234571 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -1,5 +1,6 @@ package chat.simplex.app.views +import android.content.Context import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.foundation.* @@ -7,39 +8,86 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lock import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.fragment.app.FragmentActivity +import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.SimpleButton import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.* import chat.simplex.app.views.helpers.* import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding -import kotlinx.coroutines.launch @Composable fun TerminalView(chatModel: ChatModel, close: () -> Unit) { val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) } BackHandler(onBack = close) - TerminalLayout( - chatModel.terminalItems, - composeState, - sendCommand = { - withApi { - // show "in progress" - chatModel.controller.sendCmd(CC.Console(composeState.value.message)) - composeState.value = ComposeState(useLinkPreviews = false) - // hide "in progress" + val authorized = remember { mutableStateOf(!chatModel.controller.appPrefs.performLA.get()) } + val context = LocalContext.current + LaunchedEffect(authorized.value) { + if (!authorized.value) { + runAuth(authorized = authorized, context) + } + } + if (authorized.value) { + TerminalLayout( + chatModel.terminalItems, + composeState, + sendCommand = { + withApi { + // show "in progress" + chatModel.controller.sendCmd(CC.Console(composeState.value.message)) + composeState.value = ComposeState(useLinkPreviews = false) + // hide "in progress" + } + }, + close + ) + } else { + Surface(Modifier.fillMaxSize()) { + Column(Modifier.background(MaterialTheme.colors.background)) { + CloseSheetBar(close) + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + SimpleButton( + stringResource(R.string.auth_unlock), + icon = Icons.Outlined.Lock, + click = { + runAuth(authorized = authorized, context) + } + ) + } } - }, - close + } + } +} + +private fun runAuth(authorized: MutableState, context: Context) { + authenticate( + generalGetString(R.string.auth_open_chat_console), + generalGetString(R.string.auth_log_in_using_credential), + context as FragmentActivity, + completed = { laResult -> + when (laResult) { + LAResult.Success, LAResult.Unavailable -> authorized.value = true + is LAResult.Error, LAResult.Failed -> authorized.value = false + } + } ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt new file mode 100644 index 0000000000..3298ead4ac --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt @@ -0,0 +1,509 @@ +package chat.simplex.app.views.database + +import SectionItemView +import SectionItemViewSpaceBetween +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.ZeroCornerSize +import androidx.compose.foundation.text.* +import androidx.compose.material.* +import androidx.compose.material.TextFieldDefaults.indicatorLine +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.* +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.SimplexApp +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* +import kotlin.math.log2 + +@Composable +fun DatabaseEncryptionView(m: ChatModel) { + val progressIndicator = remember { mutableStateOf(false) } + val prefs = m.controller.appPrefs + val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) } + val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) } + val storedKey = remember { val key = DatabaseUtils.getDatabaseKey(); mutableStateOf(key != null && key != "") } + // Do not do rememberSaveable on current key to prevent saving it on disk in clear text + val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.getDatabaseKey() ?: "" else "") } + val newKey = rememberSaveable { mutableStateOf("") } + val confirmNewKey = rememberSaveable { mutableStateOf("") } + + Box( + Modifier.fillMaxSize(), + ) { + DatabaseEncryptionLayout( + useKeychain, + prefs, + m.chatDbEncrypted.value, + currentKey, + newKey, + confirmNewKey, + storedKey, + initialRandomDBPassphrase, + progressIndicator, + onConfirmEncrypt = { + progressIndicator.value = true + withApi { + try { + val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value) + val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError + when { + sqliteError is SQLiteError.ErrorNotADatabase -> { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg( + generalGetString(R.string.wrong_passphrase_title), + generalGetString(R.string.enter_correct_current_passphrase) + ) + } + } + error != null -> { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database), + "failed to set storage encryption: ${error.responseType} ${error.details}" + ) + } + } + else -> { + prefs.initialRandomDBPassphrase.set(false) + initialRandomDBPassphrase.value = false + if (useKeychain.value) { + DatabaseUtils.setDatabaseKey(newKey.value) + } + resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value) + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.database_encrypted)) + } + } + } + } catch (e: Exception) { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database), e.stackTraceToString()) + } + } + } + } + ) + if (progressIndicator.value) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = HighOrLowlight, + strokeWidth = 2.5.dp + ) + } + } + } +} + +@Composable +fun DatabaseEncryptionLayout( + useKeychain: MutableState, + prefs: AppPreferences, + chatDbEncrypted: Boolean?, + currentKey: MutableState, + newKey: MutableState, + confirmNewKey: MutableState, + storedKey: MutableState, + initialRandomDBPassphrase: MutableState, + progressIndicator: MutableState, + onConfirmEncrypt: () -> Unit, +) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start, + ) { + Text( + stringResource(R.string.database_passphrase), + Modifier.padding(start = 16.dp, bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + + SectionView(null) { + SavePassphraseSetting(useKeychain.value, initialRandomDBPassphrase.value, storedKey.value, progressIndicator.value) { checked -> + if (checked) { + setUseKeychain(true, useKeychain, prefs) + } else if (storedKey.value) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.remove_passphrase_from_keychain), + text = generalGetString(R.string.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(), + confirmText = generalGetString(R.string.remove_passphrase), + onConfirm = { + DatabaseUtils.removeDatabaseKey() + setUseKeychain(false, useKeychain, prefs) + storedKey.value = false + }, + destructive = true, + ) + } else { + setUseKeychain(false, useKeychain, prefs) + } + } + + if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) { + DatabaseKeyField( + currentKey, + generalGetString(R.string.current_passphrase), + modifier = Modifier.padding(start = 8.dp), + isValid = ::validKey, + keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + ) + } + + DatabaseKeyField( + newKey, + generalGetString(R.string.new_passphrase), + modifier = Modifier.padding(start = 8.dp), + showStrength = true, + isValid = ::validKey, + keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + ) + val onClickUpdate = { + // Don't do things concurrently. Shouldn't be here concurrently, just in case + if (!progressIndicator.value) { + if (currentKey.value == "") { + if (useKeychain.value) + encryptDatabaseSavedAlert(onConfirmEncrypt) + else + encryptDatabaseAlert(onConfirmEncrypt) + } else { + if (useKeychain.value) + changeDatabaseKeySavedAlert(onConfirmEncrypt) + else + changeDatabaseKeyAlert(onConfirmEncrypt) + } + } + } + val disabled = currentKey.value == newKey.value || + newKey.value != confirmNewKey.value || + newKey.value.isEmpty() || + !validKey(currentKey.value) || + !validKey(newKey.value) || + progressIndicator.value + + DatabaseKeyField( + confirmNewKey, + generalGetString(R.string.confirm_new_passphrase), + modifier = Modifier.padding(start = 8.dp), + isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, + keyboardActions = KeyboardActions(onDone = { + if (!disabled) onClickUpdate() + defaultKeyboardAction(ImeAction.Done) + }), + ) + + SectionItemViewSpaceBetween(onClickUpdate, padding = PaddingValues(start = 8.dp, end = 12.dp), disabled = disabled) { + Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary) + } + } + + Column { + if (chatDbEncrypted == false) { + SectionTextFooter(generalGetString(R.string.database_is_not_encrypted)) + } else if (useKeychain.value) { + if (storedKey.value) { + SectionTextFooter(generalGetString(R.string.keychain_is_storing_securely)) + if (initialRandomDBPassphrase.value) { + SectionTextFooter(generalGetString(R.string.encrypted_with_random_passphrase)) + } else { + SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase)) + } + } else { + SectionTextFooter(generalGetString(R.string.keychain_allows_to_receive_ntfs)) + } + } else { + SectionTextFooter(generalGetString(R.string.you_have_to_enter_passphrase_every_time)) + SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase)) + } + } + } +} + +fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.encrypt_database_question), + text = generalGetString(R.string.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(R.string.encrypt_database), + onConfirm = onConfirm, + destructive = false, + ) +} + +fun encryptDatabaseAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.encrypt_database_question), + text = generalGetString(R.string.database_will_be_encrypted) +"\n" + storeSecurelyDanger(), + confirmText = generalGetString(R.string.encrypt_database), + onConfirm = onConfirm, + destructive = true, + ) +} + +fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.change_database_passphrase_question), + text = generalGetString(R.string.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(R.string.update_database), + onConfirm = onConfirm, + destructive = false, + ) +} + +fun changeDatabaseKeyAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.change_database_passphrase_question), + text = generalGetString(R.string.database_passphrase_will_be_updated) + "\n" + storeSecurelyDanger(), + confirmText = generalGetString(R.string.update_database), + onConfirm = onConfirm, + destructive = true, + ) +} + +@Composable +fun SavePassphraseSetting( + useKeychain: Boolean, + initialRandomDBPassphrase: Boolean, + storedKey: Boolean, + progressIndicator: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + SectionItemView() { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (storedKey) Icons.Filled.VpnKey else Icons.Filled.VpnKeyOff, + stringResource(R.string.save_passphrase_in_keychain), + tint = if (storedKey) SimplexGreen else HighOrLowlight + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + stringResource(R.string.save_passphrase_in_keychain), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + Switch( + checked = useKeychain, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colors.primary, + uncheckedThumbColor = HighOrLowlight + ), + enabled = !initialRandomDBPassphrase && !progressIndicator + ) + } + } +} + +fun resetFormAfterEncryption( + m: ChatModel, + initialRandomDBPassphrase: MutableState, + currentKey: MutableState, + newKey: MutableState, + confirmNewKey: MutableState, + storedKey: MutableState, + stored: Boolean = false, +) { + m.chatDbEncrypted.value = true + initialRandomDBPassphrase.value = false + m.controller.appPrefs.initialRandomDBPassphrase.set(false) + currentKey.value = "" + newKey.value = "" + confirmNewKey.value = "" + storedKey.value = stored +} + +fun setUseKeychain(value: Boolean, useKeychain: MutableState, prefs: AppPreferences) { + useKeychain.value = value + prefs.storeDBPassphrase.set(value) +} + +fun storeSecurelySaved() = generalGetString(R.string.store_passphrase_securely) + +fun storeSecurelyDanger() = generalGetString(R.string.store_passphrase_securely_without_recover) + +private fun operationEnded(m: ChatModel, progressIndicator: MutableState, alert: () -> Unit) { + m.chatDbChanged.value = true + progressIndicator.value = false + alert.invoke() +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun DatabaseKeyField( + key: MutableState, + placeholder: String, + modifier: Modifier = Modifier, + showStrength: Boolean = false, + isValid: (String) -> Boolean, + keyboardActions: KeyboardActions = KeyboardActions(), +) { + var valid by remember { mutableStateOf(validKey(key.value)) } + var showKey by remember { mutableStateOf(false) } + val icon = if (valid) { + if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility + } else Icons.Outlined.Error + val iconColor = if (valid) { + if (showStrength && key.value.isNotEmpty()) PassphraseStrength.check(key.value).color else HighOrLowlight + } else Color.Red + val keyboard = LocalSoftwareKeyboardController.current + val keyboardOptions = KeyboardOptions( + imeAction = if (keyboardActions.onNext != null) ImeAction.Next else ImeAction.Done, + autoCorrect = false, + keyboardType = KeyboardType.Password + ) + val state = remember { + mutableStateOf(TextFieldValue(key.value)) + } + val enabled = true + val colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Unspecified, + textColor = MaterialTheme.colors.onBackground, + focusedIndicatorColor = Color.Unspecified, + unfocusedIndicatorColor = Color.Unspecified, + ) + val color = MaterialTheme.colors.onBackground + val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize) + val interactionSource = remember { MutableInteractionSource() } + BasicTextField( + value = state.value, + modifier = modifier + .fillMaxWidth() + .background(colors.backgroundColor(enabled).value, shape) + .indicatorLine(enabled, false, interactionSource, colors) + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = TextFieldDefaults.MinHeight + ), + onValueChange = { + state.value = it + key.value = it.text + valid = isValid(it.text) + }, + cursorBrush = SolidColor(colors.cursorColor(false).value), + visualTransformation = if (showKey) + VisualTransformation.None + else + VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) }, + keyboardOptions = keyboardOptions, + keyboardActions = KeyboardActions(onDone = { + keyboard?.hide() + keyboardActions.onDone?.invoke(this) + }), + singleLine = true, + textStyle = TextStyle.Default.copy( + color = color, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ), + interactionSource = interactionSource, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.TextFieldDecorationBox( + value = state.value.text, + innerTextField = innerTextField, + placeholder = { Text(placeholder, color = HighOrLowlight) }, + singleLine = true, + enabled = enabled, + isError = !valid, + trailingIcon = { + IconButton({ showKey = !showKey }) { + Icon(icon, null, tint = iconColor) + } + }, + interactionSource = interactionSource, + contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp), + visualTransformation = VisualTransformation.None, + colors = colors + ) + } + ) +} + +// based on https://generatepasswords.org/how-to-calculate-entropy/ +private fun passphraseEntropy(s: String): Double { + var hasDigits = false + var hasUppercase = false + var hasLowercase = false + var hasSymbols = false + for (c in s) { + if (c.isDigit()) { + hasDigits = true + } else if (c.isLetter()) { + if (c.isUpperCase()) { + hasUppercase = true + } else { + hasLowercase = true + } + } else if (c.isASCII()) { + hasSymbols = true + } + } + val poolSize = (if (hasDigits) 10 else 0) + (if (hasUppercase) 26 else 0) + (if (hasLowercase) 26 else 0) + (if (hasSymbols) 32 else 0) + return s.length * log2(poolSize.toDouble()) +} + +private enum class PassphraseStrength(val color: Color) { + VERY_WEAK(Color.Red), WEAK(WarningOrange), REASONABLE(WarningYellow), STRONG(SimplexGreen); + + companion object { + fun check(s: String) = with(passphraseEntropy(s)) { + when { + this > 100 -> STRONG + this > 70 -> REASONABLE + this > 40 -> WEAK + else -> VERY_WEAK + } + } + } +} + +fun validKey(s: String): Boolean { + for (c in s) { + if (c.isWhitespace() || !c.isASCII()) { + return false + } + } + return true +} + +private fun Char.isASCII() = code in 32..126 + +@Preview +@Composable +fun PreviewDatabaseEncryptionLayout() { + SimpleXTheme { + DatabaseEncryptionLayout( + useKeychain = remember { mutableStateOf(true) }, + prefs = AppPreferences(SimplexApp.context), + chatDbEncrypted = true, + currentKey = remember { mutableStateOf("") }, + newKey = remember { mutableStateOf("") }, + confirmNewKey = remember { mutableStateOf("") }, + storedKey = remember { mutableStateOf(true) }, + initialRandomDBPassphrase = remember { mutableStateOf(true) }, + progressIndicator = remember { mutableStateOf(false) }, + onConfirmEncrypt = {}, + ) + } +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt new file mode 100644 index 0000000000..82454bf901 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt @@ -0,0 +1,199 @@ +package chat.simplex.app.views.database + +import SectionSpacer +import SectionView +import android.util.Log +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import chat.simplex.app.* +import chat.simplex.app.R +import chat.simplex.app.model.AppPreferences +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.usersettings.NotificationsMode +import kotlinx.coroutines.* + +@Composable +fun DatabaseErrorView( + chatDbStatus: State, + appPreferences: AppPreferences, +) { + val progressIndicator = remember { mutableStateOf(false) } + val dbKey = remember { mutableStateOf("") } + var storedDBKey by remember { mutableStateOf(DatabaseUtils.getDatabaseKey()) } + var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) } + val saveAndRunChatOnClick: () -> Unit = { + DatabaseUtils.setDatabaseKey(dbKey.value) + storedDBKey = dbKey.value + appPreferences.storeDBPassphrase.set(true) + useKeychain = true + appPreferences.initialRandomDBPassphrase.set(false) + runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences) + } + val title = when (chatDbStatus.value) { + is DBMigrationResult.OK -> "" + is DBMigrationResult.ErrorNotADatabase -> if (useKeychain && !storedDBKey.isNullOrEmpty()) + generalGetString(R.string.wrong_passphrase) + else + generalGetString(R.string.encrypted_database) + is DBMigrationResult.Error -> generalGetString(R.string.database_error) + is DBMigrationResult.ErrorKeychain -> generalGetString(R.string.keychain_error) + is DBMigrationResult.Unknown -> generalGetString(R.string.database_error) + null -> "" // should never be here + } + + Column( + Modifier.fillMaxWidth().fillMaxHeight().verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Center, + ) { + Text( + title, + Modifier.padding(start = 16.dp, top = 16.dp, bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + SectionView(null) { + Column( + Modifier.padding(horizontal = 8.dp, vertical = 8.dp) + ) { + val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value + when (val status = chatDbStatus.value) { + is DBMigrationResult.ErrorNotADatabase -> { + if (useKeychain && !storedDBKey.isNullOrEmpty()) { + Text(generalGetString(R.string.passphrase_is_different)) + DatabaseKeyField(dbKey, buttonEnabled) { + saveAndRunChatOnClick() + } + SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick) + SectionSpacer() + Text(String.format(generalGetString(R.string.file_with_path), status.dbFile)) + } else { + Text(generalGetString(R.string.database_passphrase_is_required)) + DatabaseKeyField(dbKey, buttonEnabled) { + if (useKeychain) saveAndRunChatOnClick() else runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences) + } + if (useKeychain) { + SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick) + } else { + OpenChatButton(buttonEnabled) { runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences) } + } + } + } + is DBMigrationResult.Error -> { + Text(String.format(generalGetString(R.string.file_with_path), status.dbFile)) + Text(String.format(generalGetString(R.string.error_with_info), status.migrationError)) + } + is DBMigrationResult.ErrorKeychain -> { + Text(generalGetString(R.string.cannot_access_keychain)) + } + is DBMigrationResult.Unknown -> { + Text(String.format(generalGetString(R.string.unknown_database_error_with_info), status.json)) + } + is DBMigrationResult.OK -> { + } + null -> { + } + } + } + } + } + if (progressIndicator.value) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = HighOrLowlight, + strokeWidth = 2.5.dp + ) + } + } +} + +private fun runChat( + dbKey: String, + chatDbStatus: State, + progressIndicator: MutableState, + prefs: AppPreferences +) = CoroutineScope(Dispatchers.Default).launch { + // Don't do things concurrently. Shouldn't be here concurrently, just in case + if (progressIndicator.value) return@launch + progressIndicator.value = true + try { + SimplexApp.context.initChatController(dbKey) + } catch (e: Exception) { + Log.d(TAG, "initializeChat ${e.stackTraceToString()}") + } + progressIndicator.value = false + when (val status = chatDbStatus.value) { + is DBMigrationResult.OK -> { + SimplexService.cancelPassphraseNotification() + when (prefs.notificationsMode.get()) { + NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start(SimplexApp.context) } + NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp() + } + } + is DBMigrationResult.ErrorNotADatabase -> { + AlertManager.shared.showAlertMsg( generalGetString(R.string.wrong_passphrase_title), generalGetString(R.string.enter_correct_passphrase)) + } + is DBMigrationResult.Error -> { + AlertManager.shared.showAlertMsg( generalGetString(R.string.database_error), status.migrationError) + } + is DBMigrationResult.ErrorKeychain -> { + AlertManager.shared.showAlertMsg( generalGetString(R.string.keychain_error)) + } + is DBMigrationResult.Unknown -> { + AlertManager.shared.showAlertMsg( generalGetString(R.string.unknown_error), status.json) + } + null -> {} + } +} + +@Composable +private fun DatabaseKeyField(text: MutableState, enabled: Boolean, onClick: (() -> Unit)? = null) { + DatabaseKeyField( + text, + generalGetString(R.string.enter_passphrase), + isValid = ::validKey, + keyboardActions = KeyboardActions(onDone = if (enabled) { + { onClick?.invoke() } + } else null + ) + ) +} + +@Composable +private fun ColumnScope.SaveAndOpenButton(enabled: Boolean, onClick: () -> Unit) { + TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) { + Text(generalGetString(R.string.save_passphrase_and_open_chat)) + } +} + +@Composable +private fun ColumnScope.OpenChatButton(enabled: Boolean, onClick: () -> Unit) { + TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) { + Text(generalGetString(R.string.open_chat)) + } +} + +@Preview +@Composable +fun PreviewChatInfoLayout() { + SimpleXTheme { + DatabaseErrorView( + remember { mutableStateOf(DBMigrationResult.ErrorNotADatabase("simplex_v1_chat.db")) }, + AppPreferences(SimplexApp.context) + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt index 11c9438313..b9c0c43995 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt @@ -15,10 +15,11 @@ import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Report +import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -28,13 +29,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.HighOrLowlight -import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.usersettings.* +import kotlinx.coroutines.* import kotlinx.datetime.* import java.io.* import java.text.SimpleDateFormat @@ -49,6 +51,7 @@ fun DatabaseView( val progressIndicator = remember { mutableStateOf(false) } val runChat = remember { mutableStateOf(m.chatRunning.value ?: true) } val prefs = m.controller.appPrefs + val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) } val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) } val chatArchiveTime = remember { mutableStateOf(prefs.chatArchiveTime.get()) } val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) } @@ -68,12 +71,14 @@ fun DatabaseView( DatabaseLayout( progressIndicator.value, runChat.value, - m.chatDbChanged.value, + useKeychain.value, + m.chatDbEncrypted.value, + m.controller.appPrefs.initialRandomDBPassphrase, importArchiveLauncher, chatArchiveName, chatArchiveTime, chatLastStart, - startChat = { startChat(m, runChat, chatLastStart, context) }, + startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) }, stopChatAlert = { stopChatAlert(m, runChat, context) }, exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) }, deleteChatAlert = { deleteChatAlert(m, progressIndicator) }, @@ -100,7 +105,9 @@ fun DatabaseView( fun DatabaseLayout( progressIndicator: Boolean, runChat: Boolean, - chatDbChanged: Boolean, + useKeyChain: Boolean, + chatDbEncrypted: Boolean?, + initialRandomDBPassphrase: Preference, importArchiveLauncher: ManagedActivityResultLauncher, chatArchiveName: MutableState, chatArchiveTime: MutableState, @@ -112,10 +119,10 @@ fun DatabaseLayout( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) ) { val stopped = !runChat - val operationsDisabled = !stopped || progressIndicator || chatDbChanged + val operationsDisabled = !stopped || progressIndicator Column( - Modifier.fillMaxWidth(), + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.Start, ) { Text( @@ -125,15 +132,30 @@ fun DatabaseLayout( ) SectionView(stringResource(R.string.run_chat_section)) { - RunChatSetting(runChat, stopped, chatDbChanged, startChat, stopChatAlert) + RunChatSetting(runChat, stopped, startChat, stopChatAlert) } SectionSpacer() SectionView(stringResource(R.string.chat_database_section)) { + val unencrypted = chatDbEncrypted == false + SettingsActionItem( + if (unencrypted) Icons.Outlined.LockOpen else if (useKeyChain) Icons.Filled.VpnKey else Icons.Outlined.Lock, + stringResource(R.string.database_passphrase), + click = showSettingsModal { DatabaseEncryptionView(it) }, + iconColor = if (unencrypted) WarningOrange else HighOrLowlight, + disabled = operationsDisabled + ) + SectionDivider() SettingsActionItem( Icons.Outlined.IosShare, stringResource(R.string.export_database), - exportArchive, + click = { + if (initialRandomDBPassphrase.get()) { + exportProhibitedAlert() + } else { + exportArchive() + } + }, textColor = MaterialTheme.colors.primary, disabled = operationsDisabled ) @@ -168,14 +190,10 @@ fun DatabaseLayout( ) } SectionTextFooter( - if (chatDbChanged) { - stringResource(R.string.restart_the_app_to_use_new_chat_database) + if (stopped) { + stringResource(R.string.you_must_use_the_most_recent_version_of_database) } else { - if (stopped) { - stringResource(R.string.you_must_use_the_most_recent_version_of_database) - } else { - stringResource(R.string.stop_chat_to_enable_database_actions) - } + stringResource(R.string.stop_chat_to_enable_database_actions) } ) } @@ -185,7 +203,6 @@ fun DatabaseLayout( fun RunChatSetting( runChat: Boolean, stopped: Boolean, - chatDbChanged: Boolean, startChat: () -> Unit, stopChatAlert: () -> Unit ) { @@ -195,13 +212,12 @@ fun RunChatSetting( Icon( if (stopped) Icons.Filled.Report else Icons.Filled.PlayArrow, chatRunningText, - tint = if (chatDbChanged) HighOrLowlight else if (stopped) Color.Red else MaterialTheme.colors.primary + tint = if (stopped) Color.Red else MaterialTheme.colors.primary ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( chatRunningText, - Modifier.padding(end = 24.dp), - color = if (chatDbChanged) HighOrLowlight else Color.Unspecified + Modifier.padding(end = 24.dp) ) Spacer(Modifier.fillMaxWidth().weight(1f)) Switch( @@ -217,7 +233,6 @@ fun RunChatSetting( checkedThumbColor = MaterialTheme.colors.primary, uncheckedThumbColor = HighOrLowlight ), - enabled = !chatDbChanged ) } } @@ -228,16 +243,28 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String { return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive) } -private fun startChat(m: ChatModel, runChat: MutableState, chatLastStart: MutableState, context: Context) { +private fun startChat(m: ChatModel, runChat: MutableState, chatLastStart: MutableState, chatDbChanged: MutableState) { withApi { try { + if (chatDbChanged.value) { + SimplexApp.context.initChatController() + chatDbChanged.value = false + } + if (m.chatDbStatus.value !is DBMigrationResult.OK) { + /** Hide current view and show [DatabaseErrorView] */ + ModalManager.shared.closeModals() + return@withApi + } m.controller.apiStartChat() runChat.value = true m.chatRunning.value = true val ts = Clock.System.now() m.controller.appPrefs.chatLastStart.set(ts) chatLastStart.value = ts - SimplexService.start(context) + when (m.controller.appPrefs.notificationsMode.get()) { + NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start(SimplexApp.context) } + NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp() + } } catch (e: Error) { runChat.value = false AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString()) @@ -250,11 +277,42 @@ private fun stopChatAlert(m: ChatModel, runChat: MutableState, context: title = generalGetString(R.string.stop_chat_question), text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database), confirmText = generalGetString(R.string.stop_chat_confirmation), - onConfirm = { stopChat(m, runChat, context) }, + onConfirm = { authStopChat(m, runChat, context) }, onDismiss = { runChat.value = true } ) } +private fun exportProhibitedAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.set_password_to_export), + text = generalGetString(R.string.set_password_to_export_desc), + ) +} + +private fun authStopChat(m: ChatModel, runChat: MutableState, context: Context) { + if (m.controller.appPrefs.performLA.get()) { + authenticate( + generalGetString(R.string.auth_stop_chat), + generalGetString(R.string.auth_log_in_using_credential), + context as FragmentActivity, + completed = { laResult -> + when (laResult) { + LAResult.Success, LAResult.Unavailable -> { + stopChat(m, runChat, context) + } + is LAResult.Error -> { + } + LAResult.Failed -> { + runChat.value = true + } + } + } + ) + } else { + stopChat(m, runChat, context) + } +} + private fun stopChat(m: ChatModel, runChat: MutableState, context: Context) { withApi { try { @@ -262,6 +320,7 @@ private fun stopChat(m: ChatModel, runChat: MutableState, context: Cont runChat.value = false m.chatRunning.value = false SimplexService.stop(context) + MessagesFetcherWorker.cancelAll() } catch (e: Error) { runChat.value = true AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString()) @@ -377,6 +436,7 @@ private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Ur try { val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString()) m.controller.apiImportArchive(config) + DatabaseUtils.removeDatabaseKey() operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database)) } @@ -429,6 +489,8 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState) { withApi { try { m.controller.apiDeleteStorage() + DatabaseUtils.removeDatabaseKey() + m.controller.appPrefs.storeDBPassphrase.set(true) operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile)) } @@ -458,7 +520,9 @@ fun PreviewDatabaseLayout() { DatabaseLayout( progressIndicator = false, runChat = true, - chatDbChanged = false, + useKeyChain = false, + chatDbEncrypted = false, + initialRandomDBPassphrase = Preference({ true }, {}), importArchiveLauncher = rememberGetContentLauncher {}, chatArchiveName = remember { mutableStateOf("dummy_archive") }, chatArchiveTime = remember { mutableStateOf(Clock.System.now()) }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt new file mode 100644 index 0000000000..431c5e177a --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt @@ -0,0 +1,77 @@ +package chat.simplex.app.views.helpers + +import android.util.Log +import chat.simplex.app.* +import chat.simplex.app.model.AppPreferences +import chat.simplex.app.model.json +import chat.simplex.app.views.usersettings.Cryptor +import kotlinx.serialization.* +import java.io.File +import java.security.SecureRandom + +object DatabaseUtils { + private val cryptor = Cryptor() + + private val appPreferences: AppPreferences by lazy { + AppPreferences(SimplexApp.context) + } + + private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword" + + fun hasDatabase(filesDirectory: String): Boolean = File(filesDirectory + File.separator + "files_chat.db").exists() + + fun getDatabaseKey(): String? { + return cryptor.decryptData( + appPreferences.encryptedDBPassphrase.get()?.toByteArrayFromBase64() ?: return null, + appPreferences.initializationVectorDBPassphrase.get()?.toByteArrayFromBase64() ?: return null, + DATABASE_PASSWORD_ALIAS, + ) + } + + fun setDatabaseKey(key: String) { + val data = cryptor.encryptText(key, DATABASE_PASSWORD_ALIAS) + appPreferences.encryptedDBPassphrase.set(data.first.toBase64String()) + appPreferences.initializationVectorDBPassphrase.set(data.second.toBase64String()) + } + + fun removeDatabaseKey() { + cryptor.deleteKey(DATABASE_PASSWORD_ALIAS) + appPreferences.encryptedDBPassphrase.set(null) + appPreferences.initializationVectorDBPassphrase.set(null) + } + + fun migrateChatDatabase(useKey: String? = null): Pair { + Log.d(TAG, "migrateChatDatabase ${appPreferences.storeDBPassphrase.get()}") + val dbPath = getFilesDirectory(SimplexApp.context) + var dbKey = "" + val useKeychain = appPreferences.storeDBPassphrase.get() + if (useKey != null) { + dbKey = useKey + } else if (useKeychain) { + if (!hasDatabase(dbPath)) { + dbKey = randomDatabasePassword() + appPreferences.initialRandomDBPassphrase.set(true) + } else { + dbKey = getDatabaseKey() ?: "" + } + } + Log.d(TAG, "migrateChatDatabase DB path: $dbPath") + val migrated = chatMigrateDB(dbPath, dbKey) + val res: DBMigrationResult = kotlin.runCatching { + json.decodeFromString(migrated) + }.getOrElse { DBMigrationResult.Unknown(migrated) } + val encrypted = dbKey != "" + return encrypted to res + } + + private fun randomDatabasePassword(): String = ByteArray(32).apply { SecureRandom().nextBytes(this) }.toBase64String() +} + +@Serializable +sealed class DBMigrationResult { + @Serializable @SerialName("ok") object OK: DBMigrationResult() + @Serializable @SerialName("errorNotADatabase") class ErrorNotADatabase(val dbFile: String): DBMigrationResult() + @Serializable @SerialName("error") class Error(val dbFile: String, val migrationError: String): DBMigrationResult() + @Serializable @SerialName("errorKeychain") object ErrorKeychain: DBMigrationResult() + @Serializable @SerialName("unknown") class Unknown(val json: String): DBMigrationResult() +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt index aff69665ad..17b856017d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.Log import androidx.work.* import chat.simplex.app.* +import chat.simplex.app.SimplexService.Companion.showPassphraseNotification import kotlinx.coroutines.* import java.util.Date import java.util.concurrent.TimeUnit @@ -51,12 +52,18 @@ class MessagesFetcherWork( return Result.success() } val durationSeconds = inputData.getInt(INPUT_DATA_DURATION, 60) + var shouldReschedule = true try { withTimeout(durationSeconds * 1000L) { val chatController = (applicationContext as SimplexApp).chatController - val user = chatController.apiGetActiveUser() ?: return@withTimeout + val chatDbStatus = chatController.chatModel.chatDbStatus.value + if (chatDbStatus != DBMigrationResult.OK) { + Log.w(TAG, "Worker: problem with the database: $chatDbStatus") + showPassphraseNotification(chatDbStatus) + shouldReschedule = false + return@withTimeout + } Log.w(TAG, "Worker: starting work") - chatController.startChat(user) // Give some time to start receiving messages delay(10_000) while (!isStopped) { @@ -75,7 +82,7 @@ class MessagesFetcherWork( Log.d(TAG, "Worker: unexpected exception: ${e.stackTraceToString()}") } - reschedule() + if (shouldReschedule) reschedule() return Result.success() } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt index bc653fa2b3..bb03fa565d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt @@ -135,9 +135,10 @@ fun SectionItemWithValue( fun SectionTextFooter(text: String) { Text( text, - Modifier.padding(horizontal = 16.dp).padding(top = 5.dp).fillMaxWidth(0.9F), + Modifier.padding(horizontal = 16.dp).padding(top = 8.dp).fillMaxWidth(0.9F), color = HighOrLowlight, - fontSize = 12.sp + lineHeight = 18.sp, + fontSize = 14.sp ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index d09fcc4ce1..12af59fc89 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -10,6 +10,7 @@ import android.provider.OpenableColumns import android.text.Spanned import android.text.SpannedString import android.text.style.* +import android.util.Base64 import android.util.Log import android.view.ViewTreeObserver import androidx.annotation.StringRes @@ -403,3 +404,7 @@ fun removeFile(context: Context, fileName: String): Boolean { } return fileDeleted } + +fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT) + +fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Cryptor.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Cryptor.kt new file mode 100644 index 0000000000..560587ff66 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Cryptor.kt @@ -0,0 +1,53 @@ +package chat.simplex.app.views.usersettings + +import android.annotation.SuppressLint +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.* +import javax.crypto.spec.GCMParameterSpec + +@SuppressLint("ObsoleteSdkInt") +internal class Cryptor { + private var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + + fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String { + val cipher: Cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, getSecretKey(alias), spec) + return String(cipher.doFinal(data)) + } + + fun encryptText(text: String, alias: String): Pair { + val cipher: Cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, createSecretKey(alias)) + return Pair(cipher.doFinal(text.toByteArray(charset("UTF-8"))), cipher.iv) + } + + fun deleteKey(alias: String) { + if (!keyStore.containsAlias(alias)) return + keyStore.deleteEntry(alias) + } + + private fun createSecretKey(alias: String): SecretKey { + if (keyStore.containsAlias(alias)) return getSecretKey(alias) + val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM, "AndroidKeyStore") + keyGenerator.init( + KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build() + ) + return keyGenerator.generateKey() + } + + private fun getSecretKey(alias: String): SecretKey { + return (keyStore.getEntry(alias, null) as KeyStore.SecretKeyEntry).secretKey + } + + companion object { + private val KEY_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + private val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM + private val TRANSFORMATION = "AES/GCM/NoPadding" + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index d74d2e9477..fd1e150a91 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -44,6 +44,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { SettingsLayout( profile = user.profile, stopped, + chatModel.chatDbEncrypted.value == true, chatModel.incognito, chatModel.controller.appPrefs.incognito, developerTools = chatModel.controller.appPrefs.developerTools, @@ -79,6 +80,7 @@ val simplexTeamUri = fun SettingsLayout( profile: LocalProfile, stopped: Boolean, + encrypted: Boolean, incognito: MutableState, incognitoPref: Preference, developerTools: Preference, @@ -113,7 +115,7 @@ fun SettingsLayout( SectionDivider() SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped) SectionDivider() - DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) + DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) } SectionSpacer() @@ -199,7 +201,7 @@ fun MaintainIncognitoState(chatModel: ChatModel) { } } -@Composable private fun DatabaseItem(openDatabaseView: () -> Unit, stopped: Boolean) { +@Composable private fun DatabaseItem(encrypted: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) { SectionItemView(openDatabaseView) { Row( Modifier.fillMaxWidth(), @@ -207,12 +209,12 @@ fun MaintainIncognitoState(chatModel: ChatModel) { ) { Row { Icon( - Icons.Outlined.Archive, - contentDescription = stringResource(R.string.database_export_and_import), - tint = HighOrLowlight, + Icons.Outlined.FolderOpen, + contentDescription = stringResource(R.string.database_passphrase_and_export), + tint = if (encrypted) HighOrLowlight else WarningOrange, ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(stringResource(R.string.database_export_and_import)) + Text(stringResource(R.string.database_passphrase_and_export)) } if (stopped) { Icon( @@ -305,9 +307,9 @@ fun MaintainIncognitoState(chatModel: ChatModel) { } @Composable -fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, disabled: Boolean = false) { +fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, iconColor: Color = HighOrLowlight, disabled: Boolean = false) { SectionItemView(click, disabled = disabled) { - Icon(icon, text, tint = HighOrLowlight) + Icon(icon, text, tint = iconColor) Spacer(Modifier.padding(horizontal = 4.dp)) Text(text, color = if (disabled) HighOrLowlight else textColor) } @@ -355,6 +357,7 @@ fun PreviewSettingsLayout() { SettingsLayout( profile = LocalProfile.sampleData, stopped = false, + encrypted = false, incognito = remember { mutableStateOf(false) }, incognitoPref = Preference({ false}, {}), developerTools = Preference({ false }, {}), diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 3ddb828a79..5c7a0da863 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -67,12 +67,21 @@ Периодические уведомления Периодические уведомления выключены! Приложение периодически получает новые сообщения — это потребляет несколько процентов батареи в день. Приложение не использует push уведомления — данные не отправляются с вашего устройства на сервер. + Введите пароль + Для получения уведомлений, пожалуйста, введите пароль от базы данных + Ошибка базы данных + Ошибка при инициализации базы данных. Нажмите чтобы узнать больше SimpleX Chat сервис Приём сообщений… Скрыть + + SimpleX Chat сообщения + SimpleX Chat звонки + SimpleX Chat звонки (экран блокировки) + Сервис уведомлений Показывать уведомления @@ -112,6 +121,8 @@ Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации. Аутентификация устройства выключена. Отключение блокировки SimpleX Chat. Повторить + Остановить чат + Открыть консоль Ответить @@ -291,7 +302,7 @@ Настройки Ваш SimpleX адрес - Экспорт и импорт архива чата + Пароль и экспорт базы Информация о SimpleX Chat Как использовать Форматирование сообщений @@ -512,11 +523,12 @@ Режим Инкогнито - Данные чата + База данных ЗАПУСТИТЬ ЧАТ Чат запущен Чат остановлен - АРХИВ ЧАТА + БАЗА ДАННЫХ + Пароль базы данных Экспорт архива чата Импорт архива чата Новый архив чата @@ -524,8 +536,10 @@ Удалить данные чата Ошибка при запуске чата Остановить чат? - Остановите чат, чтобы экспортировать или импортировать архив чата или удалить данные чата. Вы не сможете получать и отправлять сообщения, пока чат остановлен. + Остановите чат, чтобы экспортировать или импортировать архив чата или удалить базу данных. Вы не сможете получать и отправлять сообщения, пока чат остановлен. Остановить + Установите пароль + База данных зашифрована случайным паролем. Пожалуйста, поменяйте его перед экспортом. Ошибка при остановке чата Ошибка при экспорте архива чата Импортировать архив чата? @@ -541,7 +555,53 @@ Перезапустите приложение, чтобы создать новый профиль. Используйте самую последнюю версию архива чата и ТОЛЬКО на одном устройстве, иначе вы можете перестать получать сообщения от некоторых контактов. Остановите чат, чтобы разблокировать операции с архивом чата. - Перезапустите приложение, чтобы использовать новый архив чата. + + + Сохранить пароль в Keystore + База данных зашифрована! + Ошибка при шифровании + Удалить пароль из Keystore? + Уведомления будут работать только до остановки приложения! + Удалить + Зашифровать + Поменять + Текущий пароль… + Новый пароль… + Подтвердите новый пароль… + Поменять пароль + Пожалуйста, введите правильный пароль. + База данных НЕ зашифрована. Установите пароль, чтобы защитить ваши данные. + Android Keystore используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме. + База данных зашифрована случайным паролем, вы можете его поменять. + Внимание: вы не сможете восстановить или поменять пароль, если потеряете его. + Пароль базы данных будет безопасно сохранен в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления. + Пароль не сохранен на устройстве — вы будете должны ввести его при каждом запуске чата. + Зашифровать базу данных? + Поменять пароль базы данных? + База данных будет зашифрована. + База данных будет зашифрована и пароль сохранен в Keystore. + Пароль базы данных будет изменен и сохранен в Keystore. + Пароль базы данных будет изменен. + Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете. + Пожалуйста, надежно сохраните пароль, вы НЕ сможете открыть чат, если потеряете его. + + + Неправильный пароль базы данных + База данных зашифрована + Ошибка базы данных + Ошибка Keystore + Пароль базы данных отличается от сохраненного в Keystore. + Файл: %s + Введите пароль базы данных, чтобы открыть чат. + Ошибка: %s + Невозможно сохранить пароль в Keystore + Неизвестная ошибка базы данных: %s + Неправильный пароль! + Введите правильный пароль. + Неизвестная ошибка + Введите пароль… + Сохранить пароль и открыть чат + Открыть чат Чат остановлен diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index bf89ae9054..32e78d127b 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -67,12 +67,21 @@ Periodic notifications Periodic notifications are disabled! The app fetches new messages periodically — it uses a few percent of the battery per day. The app doesn\'t use push notifications — data from your device is not sent to the servers. + Passphrase is needed + To receive notifications, please, enter the database passphrase + Can\'t initialize the database + The database is not working correctly. Tap to learn more SimpleX Chat service Receiving messages… Hide + + SimpleX Chat messages + SimpleX Chat calls + SimpleX Chat calls (lock screen) + Notification service Show preview @@ -112,6 +121,8 @@ Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. Device authentication is disabled. Turning off SimpleX Lock. Retry + Stop chat + Open chat console Reply @@ -295,7 +306,7 @@ Your settings Your SimpleX contact address - Database export & import + Database passphrase & export About SimpleX Chat How to use it Markdown help @@ -518,6 +529,7 @@ Chat is running Chat is stopped CHAT DATABASE + Database passphrase Export database Import database New database archive @@ -527,6 +539,8 @@ Stop chat? Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. Stop + Set passphrase to export + Database is encrypted using a random passphrase. Please change it before exporting. Error stopping chat Error exporting chat database Import chat database? @@ -542,7 +556,53 @@ Restart the app to create a new chat profile. You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. Stop chat to enable database actions. - Restart the app to use new chat database. + + + Save passphrase in Keystore + Database encrypted! + Error encrypting database + Remove passphrase from Keystore? + Notifications will be delivered only until the app stops! + Remove + Encrypt + Update + Current passphrase… + New passphrase… + Confirm new passphrase… + Update database passphrase + Please enter correct current passphrase. + Your chat database is not encrypted - set passphrase to protect it. + Android Keystore is used to securely store passphrase - it allows notification service to work. + Database is encrypted using a random passphrase, you can change it. + Please note: you will NOT be able to recover or change passphrase if you lose it. + Android Keystore will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving notifications. + You have to enter passphrase every time the app starts - it is not stored on the device. + Encrypt database? + Change database passphrase? + Database will be encrypted. + Database will be encrypted and the passphrase stored in the Keystore. + Database encryption passphrase will be updated and stored in the Keystore. + Database encryption passphrase will be updated. + Please store passphrase securely, you will NOT be able to change it if you lose it. + Please store passphrase securely, you will NOT be able to access chat if you lose it. + + + Wrong database passphrase + Encrypted database + Database error + Keychain error + Database passphrase is different from saved in the Keystore. + File: %s + Database passphrase is required to open chat. + Error: %s + Cannot access Keystore to save database password + Unknown database error: %s + Wrong passphrase! + Enter correct passphrase. + Unknown error + Enter passphrase… + Save passphrase and open chat + Open chat Chat is stopped diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 272a327e71..9c2ee6827b 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SimpleXChat struct ContentView: View { @EnvironmentObject var chatModel: ChatModel @@ -21,6 +22,8 @@ struct ContentView: View { ZStack { if prefPerformLA && userAuthorized != true { Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") } + } else if let status = chatModel.chatDbStatus, status != .ok { + DatabaseErrorView(status: status) } else if !chatModel.v3DBMigration.startChat { MigrateToAppGroupView() } else if let step = chatModel.onboardingStage { diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index ab1739846a..19ba23766f 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -18,6 +18,8 @@ final class ChatModel: ObservableObject { @Published var currentUser: User? @Published var chatRunning: Bool? @Published var chatDbChanged = false + @Published var chatDbEncrypted: Bool? + @Published var chatDbStatus: DBMigrationResult? // list of chat "previews" @Published var chats: [Chat] = [] // current chat @@ -51,6 +53,8 @@ final class ChatModel: ObservableObject { static let shared = ChatModel() + static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } + func hasChat(_ id: String) -> Bool { chats.first(where: { $0.id == id }) != nil } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 6db4b01075..f5a866b2b4 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -94,7 +94,7 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") } DispatchQueue.main.async { - ChatModel.shared.terminalItems.append(.cmd(.now, cmd)) + ChatModel.shared.terminalItems.append(.cmd(.now, cmd.obfuscated)) ChatModel.shared.terminalItems.append(.resp(.now, resp)) } return resp @@ -185,6 +185,10 @@ func apiDeleteStorage() async throws { try await sendCommandOkResp(.apiDeleteStorage) } +func apiStorageEncryption(currentKey: String = "", newKey: String = "") async throws { + try await sendCommandOkResp(.apiStorageEncryption(config: DBEncryptionConfig(currentKey: currentKey, newKey: newKey))) +} + func apiGetChats() throws -> [ChatData] { let r = chatSendCmdSync(.apiGetChats) if case let .apiChats(chats) = r { return chats } @@ -654,22 +658,21 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws throw r } -func initializeChat(start: Bool) throws { +func initializeChat(start: Bool, dbKey: String? = nil) throws { logger.debug("initializeChat") - do { - let m = ChatModel.shared - try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) - try apiSetIncognito(incognito: incognitoGroupDefault.get()) - m.currentUser = try apiGetActiveUser() - if m.currentUser == nil { - m.onboardingStage = .step1_SimpleXInfo - } else if start { - try startChat() - } else { - m.chatRunning = false - } - } catch { - fatalError("Failed to initialize chat controller or database: \(responseError(error))") + let m = ChatModel.shared + (m.chatDbEncrypted, m.chatDbStatus) = migrateChatDatabase(dbKey) + if m.chatDbStatus != .ok { return } + let _ = getChatCtrl(dbKey) + try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) + try apiSetIncognito(incognito: incognitoGroupDefault.get()) + m.currentUser = try apiGetActiveUser() + if m.currentUser == nil { + m.onboardingStage = .step1_SimpleXInfo + } else if start { + try startChat() + } else { + m.chatRunning = false } } diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift index 2c0261ae8f..499dbbb1f7 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -19,10 +19,14 @@ let bgSuspendTimeout: Int = 5 // seconds let terminationTimeout: Int = 3 // seconds private func _suspendChat(timeout: Int) { - appStateGroupDefault.set(.suspending) - apiSuspendChat(timeoutMicroseconds: timeout * 1000000) - let endTask = beginBGTask(chatSuspended) - DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask) + if ChatModel.ok { + appStateGroupDefault.set(.suspending) + apiSuspendChat(timeoutMicroseconds: timeout * 1000000) + let endTask = beginBGTask(chatSuspended) + DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask) + } else { + appStateGroupDefault.set(.suspended) + } } func suspendChat() { @@ -47,7 +51,7 @@ func terminateChat() { case .suspending: // suspend instantly if already suspending _chatSuspended() - apiSuspendChat(timeoutMicroseconds: 0) + if ChatModel.ok { apiSuspendChat(timeoutMicroseconds: 0) } case .stopped: () default: _suspendChat(timeout: terminationTimeout) @@ -74,6 +78,6 @@ private func _chatSuspended() { func activateChat(appState: AppState = .active) { suspendLockQueue.sync { appStateGroupDefault.set(appState) - apiActivateChat() + if ChatModel.ok { apiActivateChat() } } } diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift new file mode 100644 index 0000000000..3423f8db9b --- /dev/null +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -0,0 +1,345 @@ +// +// DatabaseEncryptionView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 04/09/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +enum DatabaseEncryptionAlert: Identifiable { + case keychainRemoveKey + case encryptDatabaseSaved + case encryptDatabase + case changeDatabaseKeySaved + case changeDatabaseKey + case databaseEncrypted + case currentPassphraseError + case error(title: LocalizedStringKey, error: String = "") + + var id: String { + switch self { + case .keychainRemoveKey: return "keychainRemoveKey" + case .encryptDatabaseSaved: return "encryptDatabaseSaved" + case .encryptDatabase: return "encryptDatabase" + case .changeDatabaseKeySaved: return "changeDatabaseKeySaved" + case .changeDatabaseKey: return "changeDatabaseKey" + case .databaseEncrypted: return "databaseEncrypted" + case .currentPassphraseError: return "currentPassphraseError" + case let .error(title, _): return "error \(title)" + } + } +} + +struct DatabaseEncryptionView: View { + @EnvironmentObject private var m: ChatModel + @Binding var useKeychain: Bool + @State private var alert: DatabaseEncryptionAlert? = nil + @State private var progressIndicator = false + @State private var useKeychainToggle = storeDBPassphraseGroupDefault.get() + @State private var initialRandomDBPassphrase = initialRandomDBPassphraseGroupDefault.get() + @State private var storedKey = getDatabaseKey() != nil + @State private var currentKey = "" + @State private var newKey = "" + @State private var confirmNewKey = "" + @State private var currentKeyShown = false + + var body: some View { + ZStack { + databaseEncryptionView() + if progressIndicator { + ProgressView().scaleEffect(2) + } + } + } + + private func databaseEncryptionView() -> some View { + List { + Section { + settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : .secondary) { + Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle) + .onChange(of: useKeychainToggle) { _ in + if useKeychainToggle { + setUseKeychain(true) + } else if storedKey { + alert = .keychainRemoveKey + } else { + setUseKeychain(false) + } + } + .disabled(initialRandomDBPassphrase) + } + + if !initialRandomDBPassphrase && m.chatDbEncrypted == true { + DatabaseKeyField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey)) + } + + DatabaseKeyField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true) + DatabaseKeyField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey) + + settingsRow("lock.rotation") { + Button("Update database passphrase") { + alert = currentKey == "" + ? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase) + : (useKeychain ? .changeDatabaseKeySaved : .changeDatabaseKey) + } + } + .disabled( + currentKey == newKey || + newKey != confirmNewKey || + newKey == "" || + !validKey(currentKey) || + !validKey(newKey) + ) + } header: { + Text("") + } footer: { + VStack(alignment: .leading, spacing: 16) { + if m.chatDbEncrypted == false { + Text("Your chat database is not encrypted - set passphrase to encrypt it.") + } else if useKeychain { + if storedKey { + Text("iOS Keychain is used to securely store passphrase - it allows receiving push notifications.") + if initialRandomDBPassphrase { + Text("Database is encrypted using a random passphrase, you can change it.") + } else { + Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.") + } + } else { + Text("iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications.") + } + } else { + Text("You have to enter passphrase every time the app starts - it is not stored on the device.") + Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.") + if m.notificationMode == .instant && m.notificationPreview != .hidden { + Text("**Warning**: Instant push notifications require passphrase saved in Keychain.") + } + } + } + .padding(.top, 1) + .font(.callout) + } + } + .onAppear { + if initialRandomDBPassphrase { currentKey = getDatabaseKey() ?? "" } + } + .disabled(m.chatRunning != false) + .alert(item: $alert) { item in databaseEncryptionAlert(item) } + } + + private func encryptDatabase() { + progressIndicator = true + Task { + do { + try await apiStorageEncryption(currentKey: currentKey, newKey: newKey) + initialRandomDBPassphraseGroupDefault.set(false) + if useKeychain { + if setDatabaseKey(newKey) { + await resetFormAfterEncryption(true) + await operationEnded(.databaseEncrypted) + } else { + await resetFormAfterEncryption() + await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain")) + } + } else { + await resetFormAfterEncryption() + await operationEnded(.databaseEncrypted) + } + } catch let error { + if case .chatCmdError(.errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse { + await operationEnded(.currentPassphraseError) + } else { + await operationEnded(.error(title: "Error encrypting database", error: responseError(error))) + } + } + } + } + + private func resetFormAfterEncryption(_ stored: Bool = false) async { + await MainActor.run { + m.chatDbEncrypted = true + initialRandomDBPassphrase = false + currentKey = "" + newKey = "" + confirmNewKey = "" + storedKey = stored + } + } + + private func setUseKeychain(_ value: Bool) { + useKeychain = value + storeDBPassphraseGroupDefault.set(value) + } + + private func databaseEncryptionAlert(_ alertItem: DatabaseEncryptionAlert) -> Alert { + switch alertItem { + case .keychainRemoveKey: + return Alert( + title: Text("Remove passphrase from keychain?"), + message: Text("Instant push notifications will be hidden!\n") + storeSecurelyDanger(), + primaryButton: .destructive(Text("Remove")) { + if removeDatabaseKey() { + setUseKeychain(false) + storedKey = false + } else { + alert = .error(title: "Keychain error", error: "Failed to remove passphrase") + } + }, + secondaryButton: .cancel() { + withAnimation { useKeychainToggle = true } + } + ) + case .encryptDatabaseSaved: + return Alert( + title: Text("Encrypt database?"), + message: Text("Database will be encrypted and the passphrase stored in the keychain.\n") + storeSecurelySaved(), + primaryButton: .default(Text("Encrypt")) { encryptDatabase() }, + secondaryButton: .cancel() + ) + case .encryptDatabase: + return Alert( + title: Text("Encrypt database?"), + message: Text("Database will be encrypted.\n") + storeSecurelyDanger(), + primaryButton: .destructive(Text("Encrypt")) { encryptDatabase() }, + secondaryButton: .cancel() + ) + case .changeDatabaseKeySaved: + return Alert( + title: Text("Change database passphrase?"), + message: Text("Database encryption passphrase will be updated and stored in the keychain.\n") + storeSecurelySaved(), + primaryButton: .default(Text("Update")) { encryptDatabase() }, + secondaryButton: .cancel() + ) + case .changeDatabaseKey: + return Alert( + title: Text("Change database passphrase?"), + message: Text("Database encryption passphrase will be updated.\n") + storeSecurelyDanger(), + primaryButton: .destructive(Text("Update")) { encryptDatabase() }, + secondaryButton: .cancel() + ) + case .databaseEncrypted: + return Alert(title: Text("Database encrypted!")) + case .currentPassphraseError: + return Alert( + title: Text("Wrong passphrase!"), + message: Text("Please enter correct current passphrase.") + ) + case let .error(title, error): + return Alert(title: Text(title), message: Text("\(error)")) + } + } + + private func storeSecurelySaved() -> Text { + Text("Please store passphrase securely, you will NOT be able to change it if you lose it.") + } + + private func storeSecurelyDanger() -> Text { + Text("Please store passphrase securely, you will NOT be able to access chat if you lose it.") + } + + private func operationEnded(_ dbAlert: DatabaseEncryptionAlert) async { + await MainActor.run { + m.chatDbChanged = true + progressIndicator = false + alert = dbAlert + } + } +} + + +struct DatabaseKeyField: View { + @Binding var key: String + var placeholder: LocalizedStringKey + var valid: Bool + var showStrength = false + var onSubmit: () -> Void = {} + @State private var showKey = false + + var body: some View { + ZStack(alignment: .leading) { + let iconColor = valid + ? (showStrength && key != "" ? PassphraseStrength(passphrase: key).color : .secondary) + : .red + Image(systemName: valid ? (showKey ? "eye.slash" : "eye") : "exclamationmark.circle") + .resizable() + .scaledToFit() + .frame(width: 20, height: 22, alignment: .center) + .foregroundColor(iconColor) + .onTapGesture { showKey = !showKey } + textField() + .disableAutocorrection(true) + .autocapitalization(.none) + .submitLabel(.done) + .padding(.leading, 36) + .onSubmit(onSubmit) + } + } + + @ViewBuilder func textField() -> some View { + if showKey { + TextField(placeholder, text: $key) + } else { + SecureField(placeholder, text: $key) + } + } +} + +// based on https://generatepasswords.org/how-to-calculate-entropy/ +private func passphraseEnthropy(_ s: String) -> Double { + var hasDigits = false + var hasUppercase = false + var hasLowercase = false + var hasSymbols = false + for c in s { + if c.isNumber { + hasDigits = true + } else if c.isLetter { + if c.isUppercase { hasUppercase = true } + else { hasLowercase = true } + } else if c.isASCII { + hasSymbols = true + } + } + let poolSize: Double = (hasDigits ? 10 : 0) + (hasUppercase ? 26 : 0) + (hasLowercase ? 26 : 0) + (hasSymbols ? 32 : 0) + return Double(s.count) * log2(poolSize) +} + +enum PassphraseStrength { + case veryWeak + case weak + case reasonable + case strong + + init(passphrase s: String) { + let enthropy = passphraseEnthropy(s) + self = enthropy > 60 + ? .strong + : enthropy > 45 + ? .reasonable + : enthropy > 30 + ? .weak + : .veryWeak + } + + var color: Color { + switch self { + case .veryWeak: return .red + case .weak: return .orange + case .reasonable: return .yellow + case .strong: return .green + } + } +} + +func validKey(_ s: String) -> Bool { + for c in s { if c.isWhitespace || !c.isASCII { return false } } + return true +} + +struct DatabaseEncryptionView_Previews: PreviewProvider { + static var previews: some View { + DatabaseEncryptionView(useKeychain: Binding.constant(true)) + } +} diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift new file mode 100644 index 0000000000..9b3c8c0fb0 --- /dev/null +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -0,0 +1,127 @@ +// +// DatabaseErrorView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 04/09/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct DatabaseErrorView: View { + @EnvironmentObject var m: ChatModel + @State var status: DBMigrationResult + @State private var dbKey = "" + @State private var storedDBKey = getDatabaseKey() + @State private var useKeychain = storeDBPassphraseGroupDefault.get() + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + switch status { + case let .errorNotADatabase(dbFile): + if useKeychain && storedDBKey != nil && storedDBKey != "" { + Text("Wrong database passphrase").font(.title) + Text("Database passphrase is different from saved in the keychain.") + databaseKeyField(onSubmit: saveAndRunChat) + saveAndOpenButton() + Spacer() + Text("File: \(dbFile)") + } else { + Text("Encrypted database").font(.title) + Text("Database passphrase is required to open chat.") + if useKeychain { + databaseKeyField(onSubmit: saveAndRunChat) + saveAndOpenButton() + } else { + databaseKeyField(onSubmit: runChat) + openChatButton() + } + Spacer() + } + case let .error(dbFile, migrationError): + Text("Database error") + .font(.title) + Text("File: \(dbFile)") + Text("Error: \(migrationError)") + Spacer() + case .errorKeychain: + Text("Keychain error") + .font(.title) + Text("Cannot access keychain to save database password") + Spacer() + case let .unknown(json): + Text("Database error") + .font(.title) + Text("Unknown database error: \(json)") + Spacer() + case .ok: + EmptyView() + } + } + .padding() + .frame(maxHeight: .infinity) + } + + private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View { + DatabaseKeyField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit) + } + + private func saveAndOpenButton() -> some View { + Button("Save passphrase and open chat") { + saveAndRunChat() + } + } + + private func openChatButton() -> some View { + Button("Open chat") { + runChat() + } + } + + private func saveAndRunChat() { + if setDatabaseKey(dbKey) { + storeDBPassphraseGroupDefault.set(true) + initialRandomDBPassphraseGroupDefault.set(false) + } + runChat() + } + + private func runChat() { + do { + try initializeChat(start: m.v3DBMigration.startChat, dbKey: dbKey) + if let s = m.chatDbStatus { + status = s + let am = AlertManager.shared + switch s { + case .errorNotADatabase: + am.showAlertMsg( + title: "Wrong passphrase!", + message: "Enter correct passphrase." + ) + case .errorKeychain: + am.showAlertMsg(title: "Keychain error") + case let .error(_, error): + am.showAlert(Alert( + title: Text("Database error"), + message: Text(error) + )) + case let .unknown(error): + am.showAlert(Alert( + title: Text("Unknown error"), + message: Text(error) + )) + case .ok: () + } + } + } catch let error { + logger.error("initializeChat \(responseError(error))") + } + } +} + +struct DatabaseErrorView_Previews: PreviewProvider { + static var previews: some View { + DatabaseErrorView(status: .errorNotADatabase(dbFile: "simplex_v1_chat.db")) + } +} diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index d904cd05ca..5fe3b7b836 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -11,6 +11,7 @@ import SimpleXChat enum DatabaseAlert: Identifiable { case stopChat + case exportProhibited case importArchive case archiveImported case deleteChat @@ -21,6 +22,7 @@ enum DatabaseAlert: Identifiable { var id: String { switch self { case .stopChat: return "stopChat" + case .exportProhibited: return "exportProhibited" case .importArchive: return "importArchive" case .archiveImported: return "archiveImported" case .deleteChat: return "deleteChat" @@ -43,6 +45,7 @@ struct DatabaseView: View { @AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0 @State private var dbContainer = dbContainerGroupDefault.get() @State private var legacyDatabase = hasLegacyDatabase() + @State private var useKeychain = storeDBPassphraseGroupDefault.get() var body: some View { ZStack { @@ -82,18 +85,28 @@ struct DatabaseView: View { } Section { - settingsRow("square.and.arrow.up") { - Button { - exportArchive() + let unencrypted = m.chatDbEncrypted == false + let color: Color = unencrypted ? .orange : .secondary + settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) { + NavigationLink { + DatabaseEncryptionView(useKeychain: $useKeychain) + .navigationTitle("Database passphrase") } label: { - Text("Export database") + Text("Database passphrase") + } + } + settingsRow("square.and.arrow.up") { + Button("Export database") { + if initialRandomDBPassphraseGroupDefault.get() { + alert = .exportProhibited + } else { + exportArchive() + } } } settingsRow("square.and.arrow.down") { - Button(role: .destructive) { + Button("Import database", role: .destructive) { showFileImporter = true - } label: { - Text("Import database") } } if let archiveName = chatArchiveName { @@ -110,10 +123,8 @@ struct DatabaseView: View { } } settingsRow("trash.slash") { - Button(role: .destructive) { + Button("Delete database", role: .destructive) { alert = .deleteChat - } label: { - Text("Delete database") } } } header: { @@ -130,10 +141,8 @@ struct DatabaseView: View { if case .group = dbContainer, legacyDatabase { Section("Old database") { settingsRow("trash") { - Button { + Button("Delete old database") { alert = .deleteLegacyDatabase - } label: { - Text("Delete old database") } } } @@ -160,12 +169,17 @@ struct DatabaseView: View { title: Text("Stop chat?"), message: Text("Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped."), primaryButton: .destructive(Text("Stop")) { - stopChat() + authStopChat() }, secondaryButton: .cancel { withAnimation { runChat = true } } ) + case .exportProhibited: + return Alert( + title: Text("Set passphrase to export"), + message: Text("Database is encrypted using a random passphrase. Please change it before exporting.") + ) case .importArchive: if let fileURL = importedArchivePath { return Alert( @@ -213,6 +227,20 @@ struct DatabaseView: View { } } + private func authStopChat() { + if UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) { + authenticate(reason: NSLocalizedString("Stop SimpleX", comment: "authentication reason")) { laResult in + switch laResult { + case .success: stopChat() + case .unavailable: stopChat() + case .failed: withAnimation { runChat = true } + } + } + } else { + stopChat() + } + } + private func stopChat() { Task { do { @@ -254,6 +282,7 @@ struct DatabaseView: View { do { let config = ArchiveConfig(archivePath: archivePath.path) try await apiImportArchive(config: config) + _ = removeDatabaseKey() await operationEnded(.archiveImported) } catch let error { await operationEnded(.error(title: "Error importing chat database", error: responseError(error))) @@ -273,6 +302,8 @@ struct DatabaseView: View { Task { do { try await apiDeleteStorage() + _ = removeDatabaseKey() + storeDBPassphraseGroupDefault.set(true) await operationEnded(.chatDeleted) } catch let error { await operationEnded(.error(title: "Error deleting database", error: responseError(error))) diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 6aa9e8804c..488602dda6 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -17,8 +17,28 @@ struct TerminalView: View { @EnvironmentObject var chatModel: ChatModel @State var composeState: ComposeState = ComposeState() @FocusState private var keyboardVisible: Bool + @State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) var body: some View { + if authorized { + terminalView() + } else { + Button(action: runAuth) { Label("Unlock", systemImage: "lock") } + .onAppear(perform: runAuth) + } + } + + private func runAuth() { + authenticate(reason: NSLocalizedString("Open chat console", comment: "authentication reason")) { laResult in + switch laResult { + case .success: authorized = true + case .unavailable: authorized = true + case .failed: authorized = false + } + } + } + + private func terminalView() -> some View { VStack { ScrollViewReader { proxy in ScrollView { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 3a2fe30ae5..2fff1adc92 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -44,7 +44,7 @@ let appDefaults: [String: Any] = [ DEFAULT_ACCENT_COLOR_RED: 0.000, DEFAULT_ACCENT_COLOR_GREEN: 0.533, DEFAULT_ACCENT_COLOR_BLUE: 1.000, - DEFAULT_USER_INTERFACE_STYLE: 0 + DEFAULT_USER_INTERFACE_STYLE: 0, ] private var indent: CGFloat = 36 @@ -92,9 +92,10 @@ struct SettingsView: View { DatabaseView(showSettings: $showSettings) .navigationTitle("Your chat database") } label: { - settingsRow("internaldrive") { + let color: Color = chatModel.chatDbEncrypted == false ? .orange : .secondary + settingsRow("internaldrive", color: color) { HStack { - Text("Database export & import") + Text("Database passphrase & export") Spacer() if chatModel.chatRunning == false { Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red) diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements index 6dda31ceba..51672d6290 100644 --- a/apps/ios/SimpleX (iOS).entitlements +++ b/apps/ios/SimpleX (iOS).entitlements @@ -14,5 +14,9 @@ group.chat.simplex.app + keychain-access-groups + + $(AppIdentifierPrefix)chat.simplex.app + diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 3dba7fc0b4..4baf962d3c 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -127,6 +127,11 @@ **Paste received link** or open it in the browser and tap **Open in mobile app**. No comment provided by engineer. + + **Please note**: you will NOT be able to recover or change passphrase if you lose it. + **Please note**: you will NOT be able to recover or change passphrase if you lose it. + No comment provided by engineer. + **Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from. **Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from. @@ -137,6 +142,11 @@ **Scan QR code**: to connect to your contact in person or via video call. No comment provided by engineer. + + **Warning**: Instant push notifications require passphrase saved in Keychain. + **Warning**: Instant push notifications require passphrase saved in Keychain. + No comment provided by engineer. + **e2e encrypted** audio call **e2e encrypted** audio call @@ -303,6 +313,16 @@ Cancel No comment provided by engineer. + + Cannot access keychain to save database password + Cannot access keychain to save database password + No comment provided by engineer. + + + Change database passphrase? + Change database passphrase? + No comment provided by engineer. + Chat archive Chat archive @@ -346,7 +366,7 @@ Chats Chats - back button to return to chats list + No comment provided by engineer. Choose file @@ -388,6 +408,11 @@ Confirm No comment provided by engineer. + + Confirm new passphrase… + Confirm new passphrase… + No comment provided by engineer. + Connect Connect @@ -528,6 +553,11 @@ Created on %@ No comment provided by engineer. + + Current passphrase… + Current passphrase… + No comment provided by engineer. + Currently maximum supported file size is %@. Currently maximum supported file size is %@. @@ -543,9 +573,72 @@ Database ID No comment provided by engineer. - - Database export & import - Database export & import + + Database encrypted! + Database encrypted! + No comment provided by engineer. + + + Database encryption passphrase will be updated and stored in the keychain. + + Database encryption passphrase will be updated and stored in the keychain. + + No comment provided by engineer. + + + Database encryption passphrase will be updated. + + Database encryption passphrase will be updated. + + No comment provided by engineer. + + + Database error + Database error + No comment provided by engineer. + + + Database is encrypted using a random passphrase, you can change it. + Database is encrypted using a random passphrase, you can change it. + No comment provided by engineer. + + + Database is encrypted using a random passphrase. Please change it before exporting. + Database is encrypted using a random passphrase. Please change it before exporting. + No comment provided by engineer. + + + Database passphrase + Database passphrase + No comment provided by engineer. + + + Database passphrase & export + Database passphrase & export + No comment provided by engineer. + + + Database passphrase is different from saved in the keychain. + Database passphrase is different from saved in the keychain. + No comment provided by engineer. + + + Database passphrase is required to open chat. + Database passphrase is required to open chat. + No comment provided by engineer. + + + Database will be encrypted and the passphrase stored in the keychain. + + Database will be encrypted and the passphrase stored in the keychain. + + No comment provided by engineer. + + + Database will be encrypted. + + Database will be encrypted. + No comment provided by engineer. @@ -743,6 +836,56 @@ Enable periodic notifications? No comment provided by engineer. + + Encrypt + Encrypt + No comment provided by engineer. + + + Encrypt database? + Encrypt database? + No comment provided by engineer. + + + Encrypted database + Encrypted database + No comment provided by engineer. + + + Encrypted message or another event + Encrypted message or another event + notification + + + Encrypted message: database error + Encrypted message: database error + notification + + + Encrypted message: keychain error + Encrypted message: keychain error + notification + + + Encrypted message: no passphrase + Encrypted message: no passphrase + notification + + + Encrypted message: unexpeсted error + Encrypted message: unexpeсted error + notification + + + Enter correct passphrase. + Enter correct passphrase. + No comment provided by engineer. + + + Enter passphrase… + Enter passphrase… + No comment provided by engineer. + Error accessing database file Error accessing database file @@ -783,6 +926,11 @@ Error enabling notifications No comment provided by engineer. + + Error encrypting database + Error encrypting database + No comment provided by engineer. + Error exporting chat database Error exporting chat database @@ -863,6 +1011,11 @@ File will be received when your contact is online, please wait or check later! No comment provided by engineer. + + File: %@ + File: %@ + No comment provided by engineer. + For console For console @@ -1053,6 +1206,13 @@ Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) No comment provided by engineer. + + Instant push notifications will be hidden! + + Instant push notifications will be hidden! + + No comment provided by engineer. + Instantly Instantly @@ -1128,6 +1288,11 @@ We will be adding server redundancy to prevent lost messages. Joining group No comment provided by engineer. + + Keychain error + Keychain error + No comment provided by engineer. + Large file! Large file! @@ -1278,6 +1443,11 @@ We will be adding server redundancy to prevent lost messages. New message notification + + New passphrase… + New passphrase… + No comment provided by engineer. + No No @@ -1363,6 +1533,16 @@ We will be adding server redundancy to prevent lost messages. Open Settings No comment provided by engineer. + + Open chat + Open chat + No comment provided by engineer. + + + Open chat console + Open chat console + authentication reason + Open-source protocol and code – anybody can run the servers. Open-source protocol and code – anybody can run the servers. @@ -1423,11 +1603,26 @@ We will be adding server redundancy to prevent lost messages. Please check your network connection and try again. No comment provided by engineer. + + Please enter correct current passphrase. + Please enter correct current passphrase. + No comment provided by engineer. + Please restart the app and migrate the database to enable push notifications. Please restart the app and migrate the database to enable push notifications. No comment provided by engineer. + + Please store passphrase securely, you will NOT be able to access chat if you lose it. + Please store passphrase securely, you will NOT be able to access chat if you lose it. + No comment provided by engineer. + + + Please store passphrase securely, you will NOT be able to change it if you lose it. + Please store passphrase securely, you will NOT be able to change it if you lose it. + No comment provided by engineer. + Privacy & security Privacy & security @@ -1518,6 +1713,11 @@ We will be adding server redundancy to prevent lost messages. Remove member? No comment provided by engineer. + + Remove passphrase from keychain? + Remove passphrase from keychain? + No comment provided by engineer. + Reply Reply @@ -1588,6 +1788,16 @@ We will be adding server redundancy to prevent lost messages. Save group profile No comment provided by engineer. + + Save passphrase and open chat + Save passphrase and open chat + No comment provided by engineer. + + + Save passphrase in Keychain + Save passphrase in Keychain + No comment provided by engineer. + Saved SMP servers will be removed Saved SMP servers will be removed @@ -1648,6 +1858,11 @@ We will be adding server redundancy to prevent lost messages. Set contact name… No comment provided by engineer. + + Set passphrase to export + Set passphrase to export + No comment provided by engineer. + Set timeouts for proxy/VPN Set timeouts for proxy/VPN @@ -1723,6 +1938,11 @@ We will be adding server redundancy to prevent lost messages. Stop No comment provided by engineer. + + Stop SimpleX + Stop SimpleX + authentication reason + Stop chat to enable database actions Stop chat to enable database actions @@ -1940,6 +2160,16 @@ You will be prompted to complete authentication before this feature is enabled.< Unexpected migration state No comment provided by engineer. + + Unknown database error: %@ + Unknown database error: %@ + No comment provided by engineer. + + + Unknown error + Unknown error + No comment provided by engineer. + Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. To connect, please ask your contact to create another connection link and check that you have a stable network connection. @@ -1957,11 +2187,21 @@ To connect, please ask your contact to create another connection link and check Unmute No comment provided by engineer. + + Update + Update + No comment provided by engineer. + Update .onion hosts setting? Update .onion hosts setting? No comment provided by engineer. + + Update database passphrase + Update database passphrase + No comment provided by engineer. + Update network settings? Update network settings? @@ -2032,6 +2272,16 @@ To connect, please ask your contact to create another connection link and check When you share an incognito profile with somebody, this profile will be used for the groups they invite you to. No comment provided by engineer. + + Wrong database passphrase + Wrong database passphrase + No comment provided by engineer. + + + Wrong passphrase! + Wrong passphrase! + No comment provided by engineer. + You You @@ -2097,6 +2347,11 @@ To connect, please ask your contact to create another connection link and check You could not be verified; please try again. No comment provided by engineer. + + You have to enter passphrase every time the app starts - it is not stored on the device. + You have to enter passphrase every time the app starts - it is not stored on the device. + No comment provided by engineer. + You invited your contact You invited your contact @@ -2182,6 +2437,11 @@ To connect, please ask your contact to create another connection link and check Your chat database No comment provided by engineer. + + Your chat database is not encrypted - set passphrase to encrypt it. + Your chat database is not encrypted - set passphrase to encrypt it. + No comment provided by engineer. + Your chat profile Your chat profile @@ -2366,7 +2626,7 @@ SimpleX servers cannot see your profile. connecting (introduction invitation) No comment provided by engineer. - + connecting call… connecting call… call status @@ -2451,6 +2711,16 @@ SimpleX servers cannot see your profile. group profile updated snd group event chat item + + iOS Keychain is used to securely store passphrase - it allows receiving push notifications. + iOS Keychain is used to securely store passphrase - it allows receiving push notifications. + No comment provided by engineer. + + + iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. + iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. + No comment provided by engineer. + incognito via contact address link incognito via contact address link diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings index dc52cfe6fa..cf485752ea 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings @@ -13,6 +13,9 @@ /* No comment provided by engineer. */ "~strike~" = "\\~strike~"; +/* call status */ +"connecting call" = "connecting call…"; + /* No comment provided by engineer. */ "Connecting server…" = "Connecting to server…"; diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index d0ddbd1bed..7a869d5739 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -127,6 +127,11 @@ **Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**. No comment provided by engineer. + + **Please note**: you will NOT be able to recover or change passphrase if you lose it. + **Внимание**: вы не сможете восстановить или поменять пароль, если вы его потеряете. + No comment provided by engineer. + **Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from. **Рекомендовано**: токен устройства и уведомления отправляются на сервер SimpleX Chat, но сервер не получает сами сообщения, их размер или от кого они. @@ -137,6 +142,11 @@ **Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка. No comment provided by engineer. + + **Warning**: Instant push notifications require passphrase saved in Keychain. + **Внимание**: для работы мгновенных уведомлений пароль должен быть сохранен в Keychain. + No comment provided by engineer. + **e2e encrypted** audio call **e2e зашифрованный** аудиозвонок @@ -303,6 +313,16 @@ Отменить No comment provided by engineer. + + Cannot access keychain to save database password + Ошибка доступа к Keychain при сохранении пароля + No comment provided by engineer. + + + Change database passphrase? + Поменять пароль базы данных? + No comment provided by engineer. + Chat archive Архив чата @@ -346,7 +366,7 @@ Chats Чаты - back button to return to chats list + No comment provided by engineer. Choose file @@ -388,6 +408,11 @@ Подтвердить No comment provided by engineer. + + Confirm new passphrase… + Подтвердите новый пароль… + No comment provided by engineer. + Connect Соединиться @@ -528,6 +553,11 @@ Дата создания %@ No comment provided by engineer. + + Current passphrase… + Текущий пароль… + No comment provided by engineer. + Currently maximum supported file size is %@. Максимальный размер файла - %@. @@ -543,9 +573,72 @@ ID базы данных No comment provided by engineer. - - Database export & import - Экспорт и импорт архива чата + + Database encrypted! + База данных зашифрована! + No comment provided by engineer. + + + Database encryption passphrase will be updated and stored in the keychain. + + Пароль базы данных будет изменен и сохранен в Keychain. + + No comment provided by engineer. + + + Database encryption passphrase will be updated. + + Пароль базы данных будет изменен. + + No comment provided by engineer. + + + Database error + Ошибка базы данных + No comment provided by engineer. + + + Database is encrypted using a random passphrase, you can change it. + База данных зашифрована случайным паролем, вы можете его поменять. + No comment provided by engineer. + + + Database is encrypted using a random passphrase. Please change it before exporting. + База данных зашифрована случайным паролем. Пожалуйста, поменяйте его перед экспортом. + No comment provided by engineer. + + + Database passphrase + Пароль базы данных + No comment provided by engineer. + + + Database passphrase & export + Пароль и экспорт базы + No comment provided by engineer. + + + Database passphrase is different from saved in the keychain. + Пароль базы данных отличается от сохраненного в Keychain. + No comment provided by engineer. + + + Database passphrase is required to open chat. + Введите пароль базы данных чтобы открыть чат. + No comment provided by engineer. + + + Database will be encrypted and the passphrase stored in the keychain. + + База данных будет зашифрована и пароль сохранен в Keychain. + + No comment provided by engineer. + + + Database will be encrypted. + + База данных будет зашифрована. + No comment provided by engineer. @@ -743,6 +836,56 @@ Включить периодические уведомления? No comment provided by engineer. + + Encrypt + Зашифровать + No comment provided by engineer. + + + Encrypt database? + Зашифровать базу данных? + No comment provided by engineer. + + + Encrypted database + База данных зашифрована + No comment provided by engineer. + + + Encrypted message or another event + Зашифрованное сообщение или событие чата + notification + + + Encrypted message: database error + Зашифрованное сообщение: ошибка базы данных + notification + + + Encrypted message: keychain error + Зашифрованное сообщение: ошибка Keychain + notification + + + Encrypted message: no passphrase + Зашифрованное сообщение: пароль не сохранен + notification + + + Encrypted message: unexpeсted error + Зашифрованное сообщение: неожиданная ошибка + notification + + + Enter correct passphrase. + Введите правильный пароль. + No comment provided by engineer. + + + Enter passphrase… + Введите пароль… + No comment provided by engineer. + Error accessing database file Ошибка при доступе к данным чата @@ -783,6 +926,11 @@ Ошибка при включении уведомлений No comment provided by engineer. + + Error encrypting database + Ошибка при шифровании + No comment provided by engineer. + Error exporting chat database Ошибка при экспорте архива чата @@ -863,6 +1011,11 @@ Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже! No comment provided by engineer. + + File: %@ + Файл: %@ + No comment provided by engineer. + For console Для консоли @@ -1053,6 +1206,13 @@ [SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat) No comment provided by engineer. + + Instant push notifications will be hidden! + + Мгновенные уведомления будут скрыты! + + No comment provided by engineer. + Instantly Мгновенно @@ -1128,6 +1288,11 @@ We will be adding server redundancy to prevent lost messages. Вступление в группу No comment provided by engineer. + + Keychain error + Ошибка Keychain + No comment provided by engineer. + Large file! Большой файл! @@ -1278,6 +1443,11 @@ We will be adding server redundancy to prevent lost messages. Новое сообщение notification + + New passphrase… + Новый пароль… + No comment provided by engineer. + No Нет @@ -1363,6 +1533,16 @@ We will be adding server redundancy to prevent lost messages. Открыть Настройки No comment provided by engineer. + + Open chat + Открыть чат + No comment provided by engineer. + + + Open chat console + Открыть консоль + authentication reason + Open-source protocol and code – anybody can run the servers. Открытый протокол и код - кто угодно может запустить сервер. @@ -1423,11 +1603,26 @@ We will be adding server redundancy to prevent lost messages. Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз. No comment provided by engineer. + + Please enter correct current passphrase. + Пожалуйста, введите правильный пароль. + No comment provided by engineer. + Please restart the app and migrate the database to enable push notifications. Пожалуйста, перезапустите приложение и переместите данные чата, чтобы включить доставку уведомлений. No comment provided by engineer. + + Please store passphrase securely, you will NOT be able to access chat if you lose it. + Пожалуйста, надежно сохраните пароль, вы НЕ сможете открыть чат, если потеряете его. + No comment provided by engineer. + + + Please store passphrase securely, you will NOT be able to change it if you lose it. + Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете. + No comment provided by engineer. + Privacy & security Конфиденциальность @@ -1518,6 +1713,11 @@ We will be adding server redundancy to prevent lost messages. Удалить члена группы? No comment provided by engineer. + + Remove passphrase from keychain? + Удалить пароль из Keychain? + No comment provided by engineer. + Reply Ответить @@ -1588,6 +1788,16 @@ We will be adding server redundancy to prevent lost messages. Сохранить профиль группы No comment provided by engineer. + + Save passphrase and open chat + Сохранить пароль и открыть чат + No comment provided by engineer. + + + Save passphrase in Keychain + Сохранить пароль в Keychain + No comment provided by engineer. + Saved SMP servers will be removed Сохраненные SMP серверы будут удалены @@ -1648,6 +1858,11 @@ We will be adding server redundancy to prevent lost messages. Имя контакта… No comment provided by engineer. + + Set passphrase to export + Установите пароль + No comment provided by engineer. + Set timeouts for proxy/VPN Установить таймауты для прокси/VPN @@ -1723,6 +1938,11 @@ We will be adding server redundancy to prevent lost messages. Остановить No comment provided by engineer. + + Stop SimpleX + Остановить SimpleX + authentication reason + Stop chat to enable database actions Остановите чат, чтобы разблокировать операции с архивом чата @@ -1940,6 +2160,16 @@ You will be prompted to complete authentication before this feature is enabled.< Неожиданная ошибка при перемещении данных чата No comment provided by engineer. + + Unknown database error: %@ + Неизвестная ошибка базы данных: %@ + No comment provided by engineer. + + + Unknown error + Неизвестная ошибка + No comment provided by engineer. + Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. To connect, please ask your contact to create another connection link and check that you have a stable network connection. @@ -1957,11 +2187,21 @@ To connect, please ask your contact to create another connection link and check Уведомлять No comment provided by engineer. + + Update + Обновить + No comment provided by engineer. + Update .onion hosts setting? Обновить настройки .onion хостов? No comment provided by engineer. + + Update database passphrase + Поменять пароль + No comment provided by engineer. + Update network settings? Обновить настройки сети? @@ -2032,6 +2272,16 @@ To connect, please ask your contact to create another connection link and check Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом. No comment provided by engineer. + + Wrong database passphrase + Неправильный пароль базы данных + No comment provided by engineer. + + + Wrong passphrase! + Неправильный пароль! + No comment provided by engineer. + You Вы @@ -2097,6 +2347,11 @@ To connect, please ask your contact to create another connection link and check Верификация не удалась; пожалуйста, попробуйте ещё раз. No comment provided by engineer. + + You have to enter passphrase every time the app starts - it is not stored on the device. + Пароль не сохранен на устройстве — вы будете должны ввести его при каждом запуске чата. + No comment provided by engineer. + You invited your contact Вы пригласили ваш контакт @@ -2179,7 +2434,12 @@ To connect, please ask your contact to create another connection link and check Your chat database - Данные чата + База данных + No comment provided by engineer. + + + Your chat database is not encrypted - set passphrase to encrypt it. + База данных НЕ зашифрована. Установите пароль, чтобы защитить ваши данные. No comment provided by engineer. @@ -2366,7 +2626,7 @@ SimpleX серверы не могут получить доступ к ваше соединяется (приглашение по представлению) No comment provided by engineer. - + connecting call… звонок соединяется… call status @@ -2451,6 +2711,16 @@ SimpleX серверы не могут получить доступ к ваше профиль группы обновлен snd group event chat item + + iOS Keychain is used to securely store passphrase - it allows receiving push notifications. + iOS Keychain используется для безопасного хранения пароля - это позволяет получать мгновенные уведомления. + No comment provided by engineer. + + + iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. + Пароль базы данных будет безопасно сохранен в iOS Keychain после запуска чата или изменения пароля - это позволит получать мгновенные уведомления. + No comment provided by engineer. + incognito via contact address link инкогнито через ссылку-контакт diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings index dc52cfe6fa..cf485752ea 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings @@ -13,6 +13,9 @@ /* No comment provided by engineer. */ "~strike~" = "\\~strike~"; +/* call status */ +"connecting call" = "connecting call…"; + /* No comment provided by engineer. */ "Connecting server…" = "Connecting to server…"; diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 969645e45f..0e8ebd852c 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -105,9 +105,9 @@ class NotificationService: UNNotificationServiceExtension { if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], let nonce = ntfData["nonce"] as? String, let encNtfInfo = ntfData["message"] as? String, - let _ = startChat() { - logger.debug("NotificationService: receiveNtfMessages: chat is started") - if let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { + let dbStatus = startChat() { + if case .ok = dbStatus, + let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") if let connEntity = ntfMsgInfo.connEntity { setBestAttemptNtf(createConnectionEventNtf(connEntity)) @@ -118,9 +118,11 @@ class NotificationService: UNNotificationServiceExtension { await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count) deliverBestAttemptNtf() } + return } } - return + } else { + setBestAttemptNtf(createErrorNtf(dbStatus)) } } deliverBestAttemptNtf() @@ -151,20 +153,26 @@ class NotificationService: UNNotificationServiceExtension { } } -func startChat() -> User? { +var chatStarted = false + +func startChat() -> DBMigrationResult? { hs_init(0, nil) + if chatStarted { return .ok } + let (_, dbStatus) = migrateChatDatabase() + if dbStatus != .ok { return dbStatus } if let user = apiGetActiveUser() { logger.debug("active user \(String(describing: user))") do { try setNetworkConfig(getNetCfg()) let justStarted = try apiStartChat() + chatStarted = true if justStarted { try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try apiSetIncognito(incognito: incognitoGroupDefault.get()) chatLastStartGroupDefault.set(Date.now) Task { await receiveMessages() } } - return user + return .ok } catch { logger.error("NotificationService startChat error: \(responseError(error), privacy: .public)") } diff --git a/apps/ios/SimpleX NSE/SimpleX NSE.entitlements b/apps/ios/SimpleX NSE/SimpleX NSE.entitlements index 82cf32be67..51dea2c806 100644 --- a/apps/ios/SimpleX NSE/SimpleX NSE.entitlements +++ b/apps/ios/SimpleX NSE/SimpleX NSE.entitlements @@ -6,5 +6,9 @@ group.chat.simplex.app + keychain-access-groups + + $(AppIdentifierPrefix)chat.simplex.app + diff --git a/apps/ios/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX NSE/en.lproj/Localizable.strings new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/apps/ios/SimpleX NSE/en.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings index e08978ade2..ab09b0ac2b 100644 --- a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings @@ -38,7 +38,7 @@ "calling…" = "входящий звонок…"; /* call status */ -"connecting call…" = "звонок соединяется…"; +"connecting call" = "звонок соединяется…"; /* chat list item title */ "connecting…" = "соединяется…"; diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 060d05d5f9..c122662af3 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -13,11 +13,12 @@ 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; 5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00164328A26FBC0094D739 /* ContextMenu.swift */; }; - 5C00166A28C119300094D739 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166528C119300094D739 /* libgmp.a */; }; - 5C00166B28C119300094D739 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166628C119300094D739 /* libffi.a */; }; - 5C00166C28C119300094D739 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166728C119300094D739 /* libgmpxx.a */; }; - 5C00166D28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166828C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a */; }; - 5C00166E28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166928C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a */; }; + 5C00167528C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166F28C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG.a */; }; + 5C00167728C28A6B0094D739 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00167028C28A6B0094D739 /* libgmpxx.a */; }; + 5C00167928C28A6B0094D739 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00167128C28A6B0094D739 /* libgmp.a */; }; + 5C00167B28C28A6B0094D739 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00167228C28A6B0094D739 /* libffi.a */; }; + 5C00167D28C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00167328C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG-ghc8.10.7.a */; }; + 5C00168128C4FE760094D739 /* KeyChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00168028C4FE760094D739 /* KeyChain.swift */; }; 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; }; 5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; }; 5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; }; @@ -61,6 +62,8 @@ 5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */; }; 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */; }; 5C9C2DA92899DA6F00CC63B1 /* NetworkAndServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */; }; + 5C9CC7A928C532AB00BEF955 /* DatabaseErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9CC7A828C532AB00BEF955 /* DatabaseErrorView.swift */; }; + 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */; }; 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */; }; 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; }; 5CA059DC279559F40002BEB4 /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */; }; @@ -197,11 +200,12 @@ 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; 5C00164328A26FBC0094D739 /* ContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenu.swift; sourceTree = ""; }; - 5C00166528C119300094D739 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C00166628C119300094D739 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C00166728C119300094D739 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C00166828C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a"; sourceTree = ""; }; - 5C00166928C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a"; sourceTree = ""; }; + 5C00166F28C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG.a"; sourceTree = ""; }; + 5C00167028C28A6B0094D739 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C00167128C28A6B0094D739 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C00167228C28A6B0094D739 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C00167328C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG-ghc8.10.7.a"; sourceTree = ""; }; + 5C00168028C4FE760094D739 /* KeyChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyChain.swift; sourceTree = ""; }; 5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = ""; }; 5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = ""; }; 5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = ""; }; @@ -247,6 +251,9 @@ 5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupProfileView.swift; sourceTree = ""; }; 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedNetworkSettings.swift; sourceTree = ""; }; 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkAndServers.swift; sourceTree = ""; }; + 5C9CC7A828C532AB00BEF955 /* DatabaseErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseErrorView.swift; sourceTree = ""; }; + 5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseEncryptionView.swift; sourceTree = ""; }; + 5C9CC7B128D1F8F400BEF955 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTC.swift; sourceTree = ""; }; 5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageView.swift; sourceTree = ""; }; @@ -351,13 +358,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C00166E28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a in Frameworks */, + 5C00167728C28A6B0094D739 /* libgmpxx.a in Frameworks */, + 5C00167B28C28A6B0094D739 /* libffi.a in Frameworks */, + 5C00167D28C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG-ghc8.10.7.a in Frameworks */, + 5C00167928C28A6B0094D739 /* libgmp.a in Frameworks */, + 5C00167528C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C00166C28C119300094D739 /* libgmpxx.a in Frameworks */, - 5C00166A28C119300094D739 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5C00166B28C119300094D739 /* libffi.a in Frameworks */, - 5C00166D28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -412,11 +419,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C00166628C119300094D739 /* libffi.a */, - 5C00166528C119300094D739 /* libgmp.a */, - 5C00166728C119300094D739 /* libgmpxx.a */, - 5C00166828C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a */, - 5C00166928C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a */, + 5C00167228C28A6B0094D739 /* libffi.a */, + 5C00167128C28A6B0094D739 /* libgmp.a */, + 5C00167028C28A6B0094D739 /* libgmpxx.a */, + 5C00167328C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG-ghc8.10.7.a */, + 5C00166F28C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG.a */, ); path = Libraries; sourceTree = ""; @@ -596,6 +603,7 @@ 5CDCAD7D2818941F00503DA2 /* API.swift */, 5CDCAD80281A7E2700503DA2 /* Notifications.swift */, 64DAE1502809D9F5000DA960 /* FileUtils.swift */, + 5C00168028C4FE760094D739 /* KeyChain.swift */, 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */, 5CE2BA8A2845332200EC33A6 /* SimpleX.h */, 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */, @@ -642,6 +650,8 @@ 5C4B3B09285FB130003915F2 /* DatabaseView.swift */, 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */, 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */, + 5C9CC7A828C532AB00BEF955 /* DatabaseErrorView.swift */, + 5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */, ); path = Database; sourceTree = ""; @@ -858,6 +868,7 @@ 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, + 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */, 5C029EAA283942EA004A9677 /* CallController.swift in Sources */, @@ -934,6 +945,7 @@ 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */, 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */, 5C3F1D5A2844B4DE00EC8A82 /* ExperimentalFeaturesView.swift in Sources */, + 5C9CC7A928C532AB00BEF955 /* DatabaseErrorView.swift in Sources */, 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */, 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */, @@ -962,6 +974,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5C00168128C4FE760094D739 /* KeyChain.swift in Sources */, 5CE2BA97284537A800EC33A6 /* dummy.m in Sources */, 5CE2BA922845340900EC33A6 /* FileUtils.swift in Sources */, 5CE2BA91284533A300EC33A6 /* Notifications.swift in Sources */, @@ -1014,6 +1027,7 @@ isa = PBXVariantGroup; children = ( 5CB0BA8A2826CB3A00B3292C /* ru */, + 5C9CC7B128D1F8F400BEF955 /* en */, ); name = Localizable.strings; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index f718305d86..45362a0de7 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -10,16 +10,46 @@ import Foundation private var chatController: chat_ctrl? -public func getChatCtrl() -> chat_ctrl { +public func getChatCtrl(_ useKey: String? = nil) -> chat_ctrl { if let controller = chatController { return controller } let dbPath = getAppDatabasePath().path + let dbKey = useKey ?? getDatabaseKey() ?? "" logger.debug("getChatCtrl DB path: \(dbPath)") - var cstr = dbPath.cString(using: .utf8)! - chatController = chat_init(&cstr) - logger.debug("getChatCtrl: chat_init") + var cPath = dbPath.cString(using: .utf8)! + var cKey = dbKey.cString(using: .utf8)! + chatController = chat_init_key(&cPath, &cKey) + logger.debug("getChatCtrl: chat_init_key") return chatController! } +public func migrateChatDatabase(_ useKey: String? = nil) -> (Bool, DBMigrationResult) { + logger.debug("migrateChatDatabase \(storeDBPassphraseGroupDefault.get())") + let dbPath = getAppDatabasePath().path + var dbKey = "" + let useKeychain = storeDBPassphraseGroupDefault.get() + if let key = useKey { + dbKey = key + } else if useKeychain { + if !hasDatabase() { + dbKey = randomDatabasePassword() + initialRandomDBPassphraseGroupDefault.set(true) + } else if let key = getDatabaseKey() { + dbKey = key + } + } + logger.debug("migrateChatDatabase DB path: \(dbPath)") +// logger.debug("migrateChatDatabase DB key: \(dbKey)") + var cPath = dbPath.cString(using: .utf8)! + var cKey = dbKey.cString(using: .utf8)! + let cjson = chat_migrate_db(&cPath, &cKey)! + let res = dbMigrationResult(fromCString(cjson)) + let encrypted = dbKey != "" + if case .ok = res, useKeychain && encrypted && !setDatabaseKey(dbKey) { + return (encrypted, .errorKeychain) + } + return (encrypted, res) +} + public func resetChatCtrl() { chatController = nil } @@ -103,3 +133,24 @@ public func responseError(_ err: Error) -> String { return err.localizedDescription } } + +public enum DBMigrationResult: Decodable, Equatable { + case ok + case errorNotADatabase(dbFile: String) + case error(dbFile: String, migrationError: String) + case errorKeychain + case unknown(json: String) +} + +func dbMigrationResult(_ s: String) -> DBMigrationResult { + let d = s.data(using: .utf8)! +// TODO is there a way to do it without copying the data? e.g: +// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) +// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + do { + return try jsonDecoder.decode(DBMigrationResult.self, from: d) + } catch let error { + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") + return .unknown(json: s) + } +} diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 2299bd899a..6d548e86f3 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -24,6 +24,7 @@ public enum ChatCommand { case apiExportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage + case apiStorageEncryption(config: DBEncryptionConfig) case apiGetChats case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) @@ -88,6 +89,7 @@ public enum ChatCommand { case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" + case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))" case .apiGetChats: return "/_get chats pcc=on" case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") @@ -156,6 +158,7 @@ public enum ChatCommand { case .apiExportArchive: return "apiExportArchive" case .apiImportArchive: return "apiImportArchive" case .apiDeleteStorage: return "apiDeleteStorage" + case .apiStorageEncryption: return "apiStorageEncryption" case .apiGetChats: return "apiGetChats" case .apiGetChat: return "apiGetChat" case .apiSendMessage: return "apiSendMessage" @@ -214,6 +217,18 @@ public enum ChatCommand { func smpServersStr(smpServers: [String]) -> String { smpServers.isEmpty ? "default" : smpServers.joined(separator: ",") } + + public var obfuscated: ChatCommand { + switch self { + case let .apiStorageEncryption(cfg): + return .apiStorageEncryption(config: DBEncryptionConfig(currentKey: obfuscate(cfg.currentKey), newKey: obfuscate(cfg.newKey))) + default: return self + } + } + + private func obfuscate(_ s: String) -> String { + s == "" ? "" : "***" + } } struct APIResponse: Decodable { @@ -527,6 +542,16 @@ public struct ArchiveConfig: Encodable { } } +public struct DBEncryptionConfig: Encodable { + public init(currentKey: String, newKey: String) { + self.currentKey = currentKey + self.newKey = newKey + } + + public var currentKey: String + public var newKey: String +} + public struct NetCfg: Codable, Equatable { public var socksProxy: String? = nil public var hostMode: HostMode = .publicHost @@ -710,6 +735,7 @@ public enum ChatError: Decodable { case error(errorType: ChatErrorType) case errorAgent(agentError: AgentErrorType) case errorStore(storeError: StoreError) + case errorDatabase(databaseError: DatabaseError) } public enum ChatErrorType: Decodable { @@ -779,6 +805,19 @@ public enum StoreError: Decodable { case chatItemNotFoundByFileId(fileId: Int64) } +public enum DatabaseError: Decodable { + case errorEncrypted + case errorPlaintext + case errorNoFile(dbFile: String) + case errorExport(sqliteError: SQLiteError) + case errorOpen(sqliteError: SQLiteError) +} + +public enum SQLiteError: Decodable { + case errorNotADatabase + case error(String) +} + public enum AgentErrorType: Decodable { case CMD(cmdErr: CommandErrorType) case CONN(connErr: ConnectionErrorType) diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index d428c00832..c44ed18af9 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -24,6 +24,8 @@ let GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE = "networkTCPKeepIdle" let GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL = "networkTCPKeepIntvl" let GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT = "networkTCPKeepCnt" let GROUP_DEFAULT_INCOGNITO = "incognito" +let GROUP_DEFAULT_STORE_DB_PASSPHRASE = "storeDBPassphrase" +let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase" let APP_GROUP_NAME = "group.chat.simplex.app" @@ -39,7 +41,9 @@ public func registerGroupDefaults() { GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE: KeepAliveOpts.defaults.keepIdle, GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL: KeepAliveOpts.defaults.keepIntvl, GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT: KeepAliveOpts.defaults.keepCnt, - GROUP_DEFAULT_INCOGNITO: false + GROUP_DEFAULT_INCOGNITO: false, + GROUP_DEFAULT_STORE_DB_PASSPHRASE: true, + GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE: false ]) } @@ -96,6 +100,10 @@ public let networkUseOnionHostsGroupDefault = EnumDefault( withDefault: .no ) +public let storeDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_STORE_DB_PASSPHRASE) + +public let initialRandomDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE) + public class DateDefault { var defaults: UserDefaults var key: String diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 9f2ff52e5f..f946f21395 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1355,7 +1355,7 @@ public enum CICallStatus: String, Decodable { case .missed: return NSLocalizedString("missed call", comment: "call status") case .rejected: return NSLocalizedString("rejected call", comment: "call status") case .accepted: return NSLocalizedString("accepted call", comment: "call status") - case .negotiated: return NSLocalizedString("connecting call…", comment: "call status") + case .negotiated: return NSLocalizedString("connecting call", comment: "call status") case .progress: return NSLocalizedString("call in progress", comment: "call status") case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended call %@", comment: "call status"), CICallStatus.durationText(sec)) case .error: return NSLocalizedString("call error", comment: "call status") diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index b236712910..326741d121 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -42,11 +42,17 @@ public func getAppDatabasePath() -> URL { dbContainerGroupDefault.get() == .group ? getGroupContainerDirectory().appendingPathComponent(DB_FILE_PREFIX, isDirectory: false) : getLegacyDatabasePath() -// getLegacyDatabasePath() } public func hasLegacyDatabase() -> Bool { - let dbPath = getLegacyDatabasePath() + hasDatabaseAtPath(getLegacyDatabasePath()) +} + +public func hasDatabase() -> Bool { + hasDatabaseAtPath(getAppDatabasePath()) +} + +func hasDatabaseAtPath(_ dbPath: URL) -> Bool { let fm = FileManager.default return fm.isReadableFile(atPath: dbPath.path + "_agent.db") && fm.isReadableFile(atPath: dbPath.path + "_chat.db") diff --git a/apps/ios/SimpleXChat/KeyChain.swift b/apps/ios/SimpleXChat/KeyChain.swift new file mode 100644 index 0000000000..704c5f752c --- /dev/null +++ b/apps/ios/SimpleXChat/KeyChain.swift @@ -0,0 +1,107 @@ +// +// KeyChain.swift +// SimpleXChat +// +// Created by Evgeny on 04/09/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import Security + +private let ACCESS_POLICY: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly +private let ACCESS_GROUP: String = "5NN7GUYB6T.chat.simplex.app" +private let DATABASE_PASSWORD_ITEM: String = "databasePassword" + +public func getDatabaseKey() -> String? { + getItemString(forKey: DATABASE_PASSWORD_ITEM) +} + +public func setDatabaseKey(_ key: String) -> Bool { + setItemString(key, forKey: DATABASE_PASSWORD_ITEM) +} + +public func removeDatabaseKey() -> Bool { + deleteItem(forKey: DATABASE_PASSWORD_ITEM) +} + +func randomDatabasePassword() -> String { + var keyData = Data(count: 32) + let status = keyData.withUnsafeMutableBytes { + SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!) + } + if status == errSecSuccess { + return keyData.base64EncodedString() + } else { + logger.error("randomDatabasePassword: error \(status)") + return "" + } +} + +private func getItemData(forKey key: String) -> Data? { + var query = baseItemQuery(forKey: key) + query[kSecMatchLimit] = kSecMatchLimitOne + query[kSecReturnData] = true as AnyObject? + + var dataRef: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &dataRef) + if status != errSecSuccess && status != errSecItemNotFound { + logger.error("getItemData: error getting data for key '\(key)', error: \(status)") + } + return dataRef as? Data +} + +private func getItemString(forKey key: String) -> String? { + if let data = getItemData(forKey: key) { + return NSString(data: data, encoding: String.Encoding.utf8.rawValue) as? String + } + return nil +} + +private func setItemData(_ data: Data, forKey key: String) -> Bool { + var query = baseItemQuery(forKey: key) + var update = [NSString : AnyObject]() + update[kSecValueData] = data as AnyObject? + update[kSecAttrAccessible] = ACCESS_POLICY + var status: OSStatus + if getItemData(forKey: key) == nil { + for (key, value) in update { query[key] = value } + status = SecItemAdd(query as CFDictionary, nil) + } else { + status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + } + if status != errSecSuccess { + logger.error("setItemData: error setting data for key '\(key)', error: \(status)") + return false + } + return true +} + +private func setItemString(_ s: String, forKey key: String) -> Bool { + if let data = s.data(using: .utf8) { + return setItemData(data, forKey: key) + } + return false +} + +private func deleteItem(forKey key: String) -> Bool { + let query = baseItemQuery(forKey: key) + if getItemData(forKey: key) != nil { + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess { + logger.error("deleteItem: error deleting data for key '\(key)', error: \(status)") + return false + } + } + return true +} + +private func baseItemQuery(forKey key: String) -> [NSString : AnyObject] { + var query = [NSString : AnyObject]() + query[kSecClass] = kSecClassGenericPassword + query[kSecAttrAccount] = key as AnyObject? + #if TARGET_OS_IOS && !TARGET_OS_SIMULATOR + query[kSecAttrAccessGroup] = ACCESS_GROUP + #endif + return query +} diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 2a4a1f6b75..74f03a1bf3 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -119,6 +119,26 @@ public func createConnectionEventNtf(_ connEntity: ConnectionEntity) -> UNMutabl ) } +public func createErrorNtf(_ dbStatus: DBMigrationResult) -> UNMutableNotificationContent { + var title: String + switch dbStatus { + case .errorNotADatabase: + title = NSLocalizedString("Encrypted message: no passphrase", comment: "notification") + case .error: + title = NSLocalizedString("Encrypted message: database error", comment: "notification") + case .errorKeychain: + title = NSLocalizedString("Encrypted message: keychain error", comment: "notification") + case .unknown: + title = NSLocalizedString("Encrypted message: unexpeсted error", comment: "notification") + case .ok: + title = NSLocalizedString("Encrypted message or another event", comment: "notification") + } + return createNotification( + categoryIdentifier: ntfCategoryConnectionEvent, + title: title + ) +} + private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember, hideContent: Bool) -> String { hideContent ? NSLocalizedString("Group message:", comment: "notification") diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index e848ad5cdd..1b2f5d821b 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -15,7 +15,8 @@ extern void hs_init(int argc, char **argv[]); typedef void* chat_ctrl; -extern chat_ctrl chat_init(char *path); +extern char *chat_migrate_db(char *path, char *key); +extern chat_ctrl chat_init_key(char *path, char *key); extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); extern char *chat_recv_msg(chat_ctrl ctl); extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait); diff --git a/apps/ios/en.lproj/Localizable.strings b/apps/ios/en.lproj/Localizable.strings index dc52cfe6fa..cf485752ea 100644 --- a/apps/ios/en.lproj/Localizable.strings +++ b/apps/ios/en.lproj/Localizable.strings @@ -13,6 +13,9 @@ /* No comment provided by engineer. */ "~strike~" = "\\~strike~"; +/* call status */ +"connecting call" = "connecting call…"; + /* No comment provided by engineer. */ "Connecting server…" = "Connecting to server…"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index bed8f2d341..c90a2a31c7 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -52,12 +52,18 @@ /* No comment provided by engineer. */ "**Paste received link** or open it in the browser and tap **Open in mobile app**." = "**Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**."; +/* No comment provided by engineer. */ +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Внимание**: вы не сможете восстановить или поменять пароль, если вы его потеряете."; + /* No comment provided by engineer. */ "**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." = "**Рекомендовано**: токен устройства и уведомления отправляются на сервер SimpleX Chat, но сервер не получает сами сообщения, их размер или от кого они."; /* No comment provided by engineer. */ "**Scan QR code**: to connect to your contact in person or via video call." = "**Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка."; +/* No comment provided by engineer. */ +"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Внимание**: для работы мгновенных уведомлений пароль должен быть сохранен в Keychain."; + /* No comment provided by engineer. */ "*bold*" = "\\*жирный*"; @@ -218,6 +224,12 @@ /* No comment provided by engineer. */ "Cancel" = "Отменить"; +/* No comment provided by engineer. */ +"Cannot access keychain to save database password" = "Ошибка доступа к Keychain при сохранении пароля"; + +/* No comment provided by engineer. */ +"Change database passphrase?" = "Поменять пароль базы данных?"; + /* No comment provided by engineer. */ "Chat archive" = "Архив чата"; @@ -275,6 +287,9 @@ /* No comment provided by engineer. */ "Confirm" = "Подтвердить"; +/* No comment provided by engineer. */ +"Confirm new passphrase…" = "Подтвердите новый пароль…"; + /* No comment provided by engineer. */ "Connect" = "Соединиться"; @@ -315,7 +330,7 @@ "connecting (introduction invitation)" = "соединяется (приглашение по представлению)"; /* call status */ -"connecting call…" = "звонок соединяется…"; +"connecting call" = "звонок соединяется…"; /* No comment provided by engineer. */ "Connecting server…" = "Устанавливается соединение с сервером…"; @@ -401,6 +416,9 @@ /* No comment provided by engineer. */ "creator" = "создатель"; +/* No comment provided by engineer. */ +"Current passphrase…" = "Текущий пароль…"; + /* No comment provided by engineer. */ "Currently maximum supported file size is %@." = "Максимальный размер файла - %@."; @@ -408,11 +426,44 @@ "Dark" = "Тёмная"; /* No comment provided by engineer. */ -"Database export & import" = "Экспорт и импорт архива чата"; +"Database encrypted!" = "База данных зашифрована!"; + +/* No comment provided by engineer. */ +"Database encryption passphrase will be updated and stored in the keychain.\n" = "Пароль базы данных будет изменен и сохранен в Keychain.\n"; + +/* No comment provided by engineer. */ +"Database encryption passphrase will be updated.\n" = "Пароль базы данных будет изменен.\n"; + +/* No comment provided by engineer. */ +"Database error" = "Ошибка базы данных"; /* No comment provided by engineer. */ "Database ID" = "ID базы данных"; +/* No comment provided by engineer. */ +"Database is encrypted using a random passphrase, you can change it." = "База данных зашифрована случайным паролем, вы можете его поменять."; + +/* No comment provided by engineer. */ +"Database is encrypted using a random passphrase. Please change it before exporting." = "База данных зашифрована случайным паролем. Пожалуйста, поменяйте его перед экспортом."; + +/* No comment provided by engineer. */ +"Database passphrase" = "Пароль базы данных"; + +/* No comment provided by engineer. */ +"Database passphrase & export" = "Пароль и экспорт базы"; + +/* No comment provided by engineer. */ +"Database passphrase is different from saved in the keychain." = "Пароль базы данных отличается от сохраненного в Keychain."; + +/* No comment provided by engineer. */ +"Database passphrase is required to open chat." = "Введите пароль базы данных чтобы открыть чат."; + +/* No comment provided by engineer. */ +"Database will be encrypted and the passphrase stored in the keychain.\n" = "База данных будет зашифрована и пароль сохранен в Keychain.\n"; + +/* No comment provided by engineer. */ +"Database will be encrypted.\n" = "База данных будет зашифрована.\n"; + /* No comment provided by engineer. */ "Database will be migrated when the app restarts" = "Данные чата будут мигрированы при перезапуске"; @@ -545,12 +596,42 @@ /* No comment provided by engineer. */ "Enable TCP keep-alive" = "Включить TCP keep-alive"; +/* No comment provided by engineer. */ +"Encrypt" = "Зашифровать"; + +/* No comment provided by engineer. */ +"Encrypt database?" = "Зашифровать базу данных?"; + +/* No comment provided by engineer. */ +"Encrypted database" = "База данных зашифрована"; + +/* notification */ +"Encrypted message or another event" = "Зашифрованное сообщение или событие чата"; + +/* notification */ +"Encrypted message: database error" = "Зашифрованное сообщение: ошибка базы данных"; + +/* notification */ +"Encrypted message: keychain error" = "Зашифрованное сообщение: ошибка Keychain"; + +/* notification */ +"Encrypted message: no passphrase" = "Зашифрованное сообщение: пароль не сохранен"; + +/* notification */ +"Encrypted message: unexpeсted error" = "Зашифрованное сообщение: неожиданная ошибка"; + /* No comment provided by engineer. */ "ended" = "завершён"; /* call status */ "ended call %@" = "завершённый звонок %@"; +/* No comment provided by engineer. */ +"Enter correct passphrase." = "Введите правильный пароль."; + +/* No comment provided by engineer. */ +"Enter passphrase…" = "Введите пароль…"; + /* No comment provided by engineer. */ "error" = "ошибка"; @@ -578,6 +659,9 @@ /* No comment provided by engineer. */ "Error enabling notifications" = "Ошибка при включении уведомлений"; +/* No comment provided by engineer. */ +"Error encrypting database" = "Ошибка при шифровании"; + /* No comment provided by engineer. */ "Error exporting chat database" = "Ошибка при экспорте архива чата"; @@ -626,6 +710,9 @@ /* No comment provided by engineer. */ "File will be received when your contact is online, please wait or check later!" = "Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже!"; +/* No comment provided by engineer. */ +"File: %@" = "Файл: %@"; + /* No comment provided by engineer. */ "For console" = "Для консоли"; @@ -755,6 +842,9 @@ /* No comment provided by engineer. */ "Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "[SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat)"; +/* No comment provided by engineer. */ +"Instant push notifications will be hidden!\n" = "Мгновенные уведомления будут скрыты!\n"; + /* No comment provided by engineer. */ "Instantly" = "Мгновенно"; @@ -782,6 +872,12 @@ /* chat list item title */ "invited to connect" = "приглашение"; +/* No comment provided by engineer. */ +"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "iOS Keychain используется для безопасного хранения пароля - это позволяет получать мгновенные уведомления."; + +/* No comment provided by engineer. */ +"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Пароль базы данных будет безопасно сохранен в iOS Keychain после запуска чата или изменения пароля - это позволит получать мгновенные уведомления."; + /* No comment provided by engineer. */ "It allows having many anonymous connections without any shared data between them in a single chat profile." = "Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя."; @@ -812,6 +908,9 @@ /* No comment provided by engineer. */ "Joining group" = "Вступление в группу"; +/* No comment provided by engineer. */ +"Keychain error" = "Ошибка Keychain"; + /* No comment provided by engineer. */ "Large file!" = "Большой файл!"; @@ -920,6 +1019,9 @@ /* notification */ "New message" = "Новое сообщение"; +/* No comment provided by engineer. */ +"New passphrase…" = "Новый пароль…"; + /* No comment provided by engineer. */ "No" = "Нет"; @@ -971,6 +1073,12 @@ /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**"; +/* No comment provided by engineer. */ +"Open chat" = "Открыть чат"; + +/* authentication reason */ +"Open chat console" = "Открыть консоль"; + /* No comment provided by engineer. */ "Open Settings" = "Открыть Настройки"; @@ -1019,9 +1127,18 @@ /* No comment provided by engineer. */ "Please check your network connection and try again." = "Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз."; +/* No comment provided by engineer. */ +"Please enter correct current passphrase." = "Пожалуйста, введите правильный пароль."; + /* No comment provided by engineer. */ "Please restart the app and migrate the database to enable push notifications." = "Пожалуйста, перезапустите приложение и переместите данные чата, чтобы включить доставку уведомлений."; +/* No comment provided by engineer. */ +"Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Пожалуйста, надежно сохраните пароль, вы НЕ сможете открыть чат, если потеряете его."; + +/* No comment provided by engineer. */ +"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете."; + /* No comment provided by engineer. */ "Privacy & security" = "Конфиденциальность"; @@ -1085,6 +1202,9 @@ /* No comment provided by engineer. */ "Remove member?" = "Удалить члена группы?"; +/* No comment provided by engineer. */ +"Remove passphrase from keychain?" = "Удалить пароль из Keychain?"; + /* No comment provided by engineer. */ "removed" = "удален(а)"; @@ -1130,6 +1250,12 @@ /* No comment provided by engineer. */ "Save group profile" = "Сохранить профиль группы"; +/* No comment provided by engineer. */ +"Save passphrase and open chat" = "Сохранить пароль и открыть чат"; + +/* No comment provided by engineer. */ +"Save passphrase in Keychain" = "Сохранить пароль в Keychain"; + /* No comment provided by engineer. */ "Saved SMP servers will be removed" = "Сохраненные SMP серверы будут удалены"; @@ -1172,6 +1298,9 @@ /* No comment provided by engineer. */ "Set contact name…" = "Имя контакта…"; +/* No comment provided by engineer. */ +"Set passphrase to export" = "Установите пароль"; + /* No comment provided by engineer. */ "Set timeouts for proxy/VPN" = "Установить таймауты для прокси/VPN"; @@ -1235,6 +1364,9 @@ /* No comment provided by engineer. */ "Stop chat?" = "Остановить чат?"; +/* authentication reason */ +"Stop SimpleX" = "Остановить SimpleX"; + /* No comment provided by engineer. */ "strike" = "зачеркнуть"; @@ -1364,6 +1496,12 @@ /* connection info */ "unknown" = "неизвестно"; +/* No comment provided by engineer. */ +"Unknown database error: %@" = "Неизвестная ошибка базы данных: %@"; + +/* No comment provided by engineer. */ +"Unknown error" = "Неизвестная ошибка"; + /* No comment provided by engineer. */ "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью."; @@ -1373,9 +1511,15 @@ /* No comment provided by engineer. */ "Unmute" = "Уведомлять"; +/* No comment provided by engineer. */ +"Update" = "Обновить"; + /* No comment provided by engineer. */ "Update .onion hosts setting?" = "Обновить настройки .onion хостов?"; +/* No comment provided by engineer. */ +"Update database passphrase" = "Поменять пароль"; + /* No comment provided by engineer. */ "Update network settings?" = "Обновить настройки сети?"; @@ -1445,6 +1589,12 @@ /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом."; +/* No comment provided by engineer. */ +"Wrong database passphrase" = "Неправильный пароль базы данных"; + +/* No comment provided by engineer. */ +"Wrong passphrase!" = "Неправильный пароль!"; + /* No comment provided by engineer. */ "You" = "Вы"; @@ -1487,6 +1637,9 @@ /* No comment provided by engineer. */ "You could not be verified; please try again." = "Верификация не удалась; пожалуйста, попробуйте ещё раз."; +/* No comment provided by engineer. */ +"You have to enter passphrase every time the app starts - it is not stored on the device." = "Пароль не сохранен на устройстве — вы будете должны ввести его при каждом запуске чата."; + /* No comment provided by engineer. */ "You invited your contact" = "Вы пригласили ваш контакт"; @@ -1545,7 +1698,10 @@ "Your chat address" = "Ваш SimpleX адрес"; /* No comment provided by engineer. */ -"Your chat database" = "Данные чата"; +"Your chat database" = "База данных"; + +/* No comment provided by engineer. */ +"Your chat database is not encrypted - set passphrase to encrypt it." = "База данных НЕ зашифрована. Установите пароль, чтобы защитить ваши данные."; /* No comment provided by engineer. */ "Your chat profile" = "Ваш профиль"; diff --git a/cabal.project b/cabal.project index b1d121e1da..ff67741368 100644 --- a/cabal.project +++ b/cabal.project @@ -1,11 +1,23 @@ packages: . +-- packages: . ../simplexmq +-- packages: . ../simplexmq ../direct-sqlcipher ../sqlcipher-simple constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: e328ae5d060645a8ef090b1b3d88bc20a5902e45 + tag: f08d81252deb68eb4a1ce4502712b99264b6749d + +source-repository-package + type: git + location: https://github.com/simplex-chat/direct-sqlcipher.git + tag: 34309410eb2069b029b8fc1872deb1e0db123294 + +source-repository-package + type: git + location: https://github.com/simplex-chat/sqlcipher-simple.git + tag: 5e154a2aeccc33ead6c243ec07195ab673137221 source-repository-package type: git diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000000..9ae8ec9512 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,16 @@ +# Contributing guide + +## Compiling with SQLCipher encryption enabled + +Add `cabal.project.local` to project root with the location of OpenSSL headers and libraries and flag setting encryption mode: + +``` +ignore-project: False + +package direct-sqlcipher + extra-include-dirs: /opt/homebrew/opt/openssl@3/include + extra-lib-dirs: /opt/homebrew/opt/openssl@3/lib + flags: +openssl +``` + +OpenSSL can be installed with `brew install openssl` diff --git a/docs/rfcs/2022-08-29-database-encryption.md b/docs/rfcs/2022-08-29-database-encryption.md new file mode 100644 index 0000000000..367201ab97 --- /dev/null +++ b/docs/rfcs/2022-08-29-database-encryption.md @@ -0,0 +1,25 @@ +# Database encryption + +## Approach + +Using SQLCipher - it is a drop in replacement for SQLite that works for non-encrypted databases without any changes (TODO test on iOS/Android). + +`direct-sqlite` and `sqlite-simple` libraries are forked and renamed to `direct-sqlcipher` and `sqlcipher-simple`, with replaced cbits in `direct-sqlcipher` (TODO include SQLCipher as git submodule with a script to upgrade cbits). + +While SQLCipher provides additional C functions to set and change database key, they do not necessarily need to be exported as they are available as PRAGMAs. + +Moving from plaintext to encrypted database (and back) requires migration process using [sqlcipher_export() function](https://discuss.zetetic.net/t/how-to-encrypt-a-plaintext-sqlite-database-to-use-sqlcipher-and-avoid-file-is-encrypted-or-is-not-a-database-errors/868). + +The approach would be similar to database migration for the notifications: + +1. the current users will be offered to migrate to encrypted database once, with a notice that it can be done later via settings. +2. the new users will be asked to enter a pass-phrase to create a new database (it can be empty, in which case the database won't be encrypted). +3. during the migration the database backup will be created and the old database files will be preserved - in case of the app failing to open the new database right after the migration it should revert to using the previous database. + +When opening the database the key must be passed via chat command / agent configuration, some test query must be performed to check that the key is correct: https://www.zetetic.net/sqlcipher/sqlcipher-api/#PRAGMA_key + +Options to support in chat settings: + +- encrypt database (with automatic rollback in case of failure) +- decrypt database (-"-) +- change key (using [PRAGMA rekey](https://www.zetetic.net/sqlcipher/sqlcipher-api/#rekey)) diff --git a/flake.nix b/flake.nix index a5e5329c72..69ecf3e507 100644 --- a/flake.nix +++ b/flake.nix @@ -25,7 +25,7 @@ }; sha256map = import ./scripts/nix/sha256map.nix; modules = [{ - packages.direct-sqlite.patches = [ ./scripts/nix/direct-sqlite-2.3.26.patch ]; + packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-2.3.27.patch ]; packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; } ({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) { @@ -118,13 +118,25 @@ > $out/nix-support/hydra-build-products ''; }; - "aarch64-android:lib:simplex-chat" = (drv androidPkgs).simplex-chat.components.library.override { + "aarch64-android:lib:simplex-chat" = (drv' { + pkgs' = androidPkgs; + extra-modules = [{ + packages.direct-sqlcipher.flags.openssl = true; + packages.direct-sqlcipher.components.library.libs = pkgs.lib.mkForce [ + (androidPkgs.openssl.override { static = true; }) + ]; + packages.direct-sqlcipher.patches = [ + ./scripts/nix/direct-sqlcipher-android-log.patch + ]; + }]; + }).simplex-chat.components.library.override { smallAddressSpace = true; enableShared = false; # for android we build a shared library, passing these arguments is a bit tricky, as # we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for # template haskell cross compilation. Thus we just pass them as linker options (-optl). setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi"]; postInstall = '' + set -x ${pkgs.tree}/bin/tree $out mkdir -p $out/_pkg # copy over includes, we might want those, but maybe not. @@ -138,8 +150,26 @@ # find ${androidPkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; # find ${androidIconv}/lib -name "*.a" -exec cp {} $out/_pkg \; # find ${androidPkgs.stdenv.cc.libc}/lib -name "*.a" -exec cp {} $out/_pkg \; + echo ${androidPkgs.openssl} + find ${androidPkgs.openssl.out}/lib -name "*.so" -exec cp {} $out/_pkg \; - ${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsimplex.so + # remove the .1 and other version suffixes from .so's. Androids linker + # doesn't play nice with them. + for lib in $out/_pkg/*.so; do + for dep in $(${pkgs.patchelf}/bin/patchelf --print-needed "$lib"); do + if [[ "''${dep##*.so}" ]]; then + echo "$lib : $dep -> ''${dep%%.so*}.so" + chmod +w "$lib" + ${pkgs.patchelf}/bin/patchelf --replace-needed "$dep" "''${dep%%.so*}.so" "$lib" + fi + done + done + + for lib in $out/_pkg/*.so; do + chmod +w "$lib" + ${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so "$lib" + [[ "$lib" != *libsimplex.so ]] && ${pkgs.patchelf}/bin/patchelf --set-soname "$(basename -a $lib)" "$lib" + done ${pkgs.tree}/bin/tree $out/_pkg (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-aarch64-android-libsimplex.zip *) @@ -165,7 +195,12 @@ > $out/nix-support/hydra-build-products ''; }; - "x86_64-android:lib:simplex-chat" = (drv androidPkgs).simplex-chat.components.library.override { + "x86_64-android:lib:simplex-chat" = (drv' { + pkgs' = androidPkgs; + extra-modules = [{ + packages.direct-sqlcipher.flags.openssl = true; + }]; + }).simplex-chat.components.library.override { smallAddressSpace = true; enableShared = false; # for android we build a shared library, passing these arguments is a bit tricky, as # we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for @@ -212,7 +247,12 @@ > $out/nix-support/hydra-build-products ''; }; - "x86_64-linux:lib:simplex-chat" = (drv androidPkgs).simplex-chat.components.library.override { + "x86_64-linux:lib:simplex-chat" = (drv' { + pkgs' = androidPkgs; + extra-modules = [{ + packages.direct-sqlcipher.flags.openssl = true; + }]; + }).simplex-chat.components.library.override { smallAddressSpace = true; enableShared = false; # for android we build a shared library, passing these arguments is a bit tricky, as # we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for @@ -250,25 +290,41 @@ # aarch64-darwin iOS build (to be patched with mac2ios) "aarch64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; - extra-modules = [{ packages.simplexmq.flags.swift = true; }]; + extra-modules = [{ + packages.simplexmq.flags.swift = true; + packages.direct-sqlcipher.flags.commoncrypto = true; + }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-swift-json" ); # aarch64-darwin build with tagged JSON format (for Mac & Flutter) - "aarch64-darwin:lib:simplex-chat" = (drv pkgs).simplex-chat.components.library.override ( + "aarch64-darwin:lib:simplex-chat" = (drv' { + pkgs' = pkgs; + extra-modules = [{ + packages.direct-sqlcipher.flags.commoncrypto = true; + }]; + }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-tagged-json" ); }; "x86_64-darwin" = { - # this is the x86_64-darwin iOS simulator build (to be patched with mac2ios) + # x86_64-darwin iOS simulator build (to be patched with mac2ios) "x86_64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; - extra-modules = [{ packages.simplexmq.flags.swift = true; }]; + extra-modules = [{ + packages.simplexmq.flags.swift = true; + packages.direct-sqlcipher.flags.commoncrypto = true; + }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-swift-json" ); - # This is the x86_64-darwin build with tagged JSON format (for Mac & Flutter iOS simulator) - "x86_64-darwin:lib:simplex-chat" = (drv pkgs).simplex-chat.components.library.override ( + # x86_64-darwin build with tagged JSON format (for Mac & Flutter iOS simulator) + "x86_64-darwin:lib:simplex-chat" = (drv' { + pkgs' = pkgs; + extra-modules = [{ + packages.direct-sqlcipher.flags.commoncrypto = true; + }]; + }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-tagged-json" ); }; diff --git a/package.yaml b/package.yaml index 5a86bdba40..5e7e44cf32 100644 --- a/package.yaml +++ b/package.yaml @@ -23,6 +23,7 @@ dependencies: - containers == 0.6.* - cryptonite >= 0.27 && < 0.30 - directory == 1.3.* + - direct-sqlcipher == 2.3.* - email-validate == 2.3.* - exceptions == 0.10.* - filepath == 1.4.* @@ -35,7 +36,7 @@ dependencies: - simple-logger == 0.1.* - simplexmq >= 3.0 - socks == 0.6.* - - sqlite-simple == 0.4.* + - sqlcipher-simple == 0.4.* - stm == 2.5.* - terminal == 0.2.* - text == 1.2.* diff --git a/scripts/android/prepare.sh b/scripts/android/prepare.sh index 363d7f7edb..a7ee5f7e8a 100755 --- a/scripts/android/prepare.sh +++ b/scripts/android/prepare.sh @@ -2,5 +2,6 @@ # libsimplex.so and libsupport.so binaries should be in ~/Downloads folder rm ./apps/android/app/src/main/cpp/libs/arm64-v8a/* -cp ~/Downloads/libsimplex.so ./apps/android/app/src/main/cpp/libs/arm64-v8a/ cp ~/Downloads/libsupport.so ./apps/android/app/src/main/cpp/libs/arm64-v8a/ +cp ~/Downloads/pkg-aarch64-android-libsimplex/libsimplex.so ./apps/android/app/src/main/cpp/libs/arm64-v8a/ +cp ~/Downloads/pkg-aarch64-android-libsimplex/libcrypto.so ./apps/android/app/src/main/cpp/libs/arm64-v8a/ diff --git a/scripts/nix/direct-sqlcipher-2.3.27.patch b/scripts/nix/direct-sqlcipher-2.3.27.patch new file mode 100644 index 0000000000..3fec71357a --- /dev/null +++ b/scripts/nix/direct-sqlcipher-2.3.27.patch @@ -0,0 +1,12 @@ +diff --git a/direct-sqlcipher.cabal b/direct-sqlcipher.cabal +index 728ba3e..c63745e 100644 +--- a/direct-sqlcipher.cabal ++++ b/direct-sqlcipher.cabal +@@ -84,6 +84,8 @@ library + cc-options: -DSQLITE_TEMP_STORE=2 + -DSQLITE_HAS_CODEC + ++ extra-libraries: dl ++ + if !os(windows) && !os(android) + extra-libraries: pthread diff --git a/scripts/nix/direct-sqlcipher-android-log.patch b/scripts/nix/direct-sqlcipher-android-log.patch new file mode 100644 index 0000000000..e6df2f7942 --- /dev/null +++ b/scripts/nix/direct-sqlcipher-android-log.patch @@ -0,0 +1,68 @@ +diff --git a/cbits/sqlite3.c b/cbits/sqlite3.c +index 66bb609..00c33c1 100644 +--- a/cbits/sqlite3.c ++++ b/cbits/sqlite3.c +@@ -101739,9 +101739,9 @@ sqlite3_mutex* sqlcipher_mutex(int); + /* #include "pager.h" */ + /* #include "vdbeInt.h" */ + +-#ifdef __ANDROID__ +-#include +-#endif ++// #ifdef __ANDROID__ ++// #include ++// #endif + + /* #include */ + +@@ -104934,11 +104934,11 @@ static int sqlcipher_profile_callback(unsigned int trace, void *file, void *stmt + FILE *f = (FILE*) file; + char *fmt = "Elapsed time:%.3f ms - %s\n"; + double elapsed = (*((sqlite3_uint64*)run_time))/1000000.0; +-#ifdef __ANDROID__ +- if(f == NULL) { +- __android_log_print(ANDROID_LOG_DEBUG, "sqlcipher", fmt, elapsed, sqlite3_sql((sqlite3_stmt*)stmt)); +- } +-#endif ++// #ifdef __ANDROID__ ++// if(f == NULL) { ++// __android_log_print(ANDROID_LOG_DEBUG, "sqlcipher", fmt, elapsed, sqlite3_sql((sqlite3_stmt*)stmt)); ++// } ++// #endif + if(f) fprintf(f, fmt, elapsed, sqlite3_sql((sqlite3_stmt*)stmt)); + return SQLITE_OK; + } +@@ -104988,12 +104988,12 @@ void sqlcipher_log(unsigned int level, const char *message, ...) { + va_start(params, message); + + #ifdef CODEC_DEBUG +-#ifdef __ANDROID__ +- __android_log_vprint(ANDROID_LOG_DEBUG, "sqlcipher", message, params); +-#else ++// #ifdef __ANDROID__ ++// __android_log_vprint(ANDROID_LOG_DEBUG, "sqlcipher", message, params); ++// #else + vfprintf(stderr, message, params); + fprintf(stderr, "\n"); +-#endif ++// #endif + #endif + + if(level > sqlcipher_log_level || (sqlcipher_log_logcat == 0 && sqlcipher_log_file == NULL)) { +@@ -105026,11 +105026,11 @@ void sqlcipher_log(unsigned int level, const char *message, ...) { + fprintf((FILE*)sqlcipher_log_file, "\n"); + } + } +-#ifdef __ANDROID__ +- if(sqlcipher_log_logcat) { +- __android_log_vprint(ANDROID_LOG_DEBUG, "sqlcipher", message, params); +- } +-#endif ++// #ifdef __ANDROID__ ++// if(sqlcipher_log_logcat) { ++// __android_log_vprint(ANDROID_LOG_DEBUG, "sqlcipher", message, params); ++// } ++// #endif + end: + va_end(params); + } diff --git a/scripts/nix/direct-sqlite-2.3.26.patch b/scripts/nix/direct-sqlite-2.3.26.patch deleted file mode 100644 index 9ac2196ddd..0000000000 --- a/scripts/nix/direct-sqlite-2.3.26.patch +++ /dev/null @@ -1,15 +0,0 @@ -diff --git a/direct-sqlite.cabal b/direct-sqlite.cabal -index 96f26b7..996198e 100644 ---- a/direct-sqlite.cabal -+++ b/direct-sqlite.cabal -@@ -69,7 +69,9 @@ library - install-includes: sqlite3.h, sqlite3ext.h - include-dirs: cbits - -- if !os(windows) && !os(android) -+ extra-libraries: dl -+ -+ if !os(windows) && !os(android) - extra-libraries: pthread - - if flag(fulltextsearch) diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 5b569f3f19..ca7209ffba 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,7 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e328ae5d060645a8ef090b1b3d88bc20a5902e45" = "0y9k1v2ss7f68md01azhh55b7xbk654hcpmyjkxkazy261nn9rxg"; + "https://github.com/simplex-chat/simplexmq.git"."f08d81252deb68eb4a1ce4502712b99264b6749d" = "0v3z9sfy7vjbgm3cznb7vaycn2r2yav83x74khf6a001cs1zv171"; + "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; + "https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index da1242c3fb..ac41f0d7e1 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -78,6 +78,7 @@ library , composition ==1.0.* , containers ==0.6.* , cryptonite >=0.27 && <0.30 + , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* @@ -91,7 +92,7 @@ library , simple-logger ==0.1.* , simplexmq >=3.0 , socks ==0.6.* - , sqlite-simple ==0.4.* + , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* , text ==1.2.* @@ -119,6 +120,7 @@ executable simplex-bot , composition ==1.0.* , containers ==0.6.* , cryptonite >=0.27 && <0.30 + , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* @@ -133,7 +135,7 @@ executable simplex-bot , simplex-chat , simplexmq >=3.0 , socks ==0.6.* - , sqlite-simple ==0.4.* + , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* , text ==1.2.* @@ -161,6 +163,7 @@ executable simplex-bot-advanced , composition ==1.0.* , containers ==0.6.* , cryptonite >=0.27 && <0.30 + , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* @@ -175,7 +178,7 @@ executable simplex-bot-advanced , simplex-chat , simplexmq >=3.0 , socks ==0.6.* - , sqlite-simple ==0.4.* + , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* , text ==1.2.* @@ -204,6 +207,7 @@ executable simplex-chat , composition ==1.0.* , containers ==0.6.* , cryptonite >=0.27 && <0.30 + , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* @@ -218,7 +222,7 @@ executable simplex-chat , simplex-chat , simplexmq >=3.0 , socks ==0.6.* - , sqlite-simple ==0.4.* + , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* , text ==1.2.* @@ -255,6 +259,7 @@ test-suite simplex-chat-test , containers ==0.6.* , cryptonite >=0.27 && <0.30 , deepseq ==1.4.* + , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* @@ -270,7 +275,7 @@ test-suite simplex-chat-test , simplex-chat , simplexmq >=3.0 , socks ==0.6.* - , sqlite-simple ==0.4.* + , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* , text ==1.2.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index a2fa24b55f..a790e66b8e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -86,6 +86,7 @@ defaultChatConfig = defaultAgentConfig { tcpPort = undefined, -- agent does not listen to TCP dbFile = "simplex_v1", + dbKey = "", yesToMigrations = False }, yesToMigrations = False, @@ -124,7 +125,7 @@ logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} newChatController :: SQLiteStore -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController -newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {dbFilePrefix, smpServers, networkConfig, logConnections, logServerHosts} sendToast = do +newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {dbFilePrefix, dbKey, smpServers, networkConfig, logConnections, logServerHosts} sendToast = do let f = chatStoreFile dbFilePrefix config = cfg {subscriptionEvents = logConnections, hostEvents = logServerHosts} sendNotification = fromMaybe (const $ pure ()) sendToast @@ -132,7 +133,7 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, de firstTime <- not <$> doesFileExist f currentUser <- newTVarIO user servers <- resolveServers defaultServers - smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db"} servers {netCfg = networkConfig} + smpAgent <- getSMPAgentClient aCfg {dbFile = agentStoreFile dbFilePrefix, dbKey} servers {netCfg = networkConfig} agentAsync <- newTVarIO Nothing idsDrg <- newTVarIO =<< drgNew inputQ <- newTBQueueIO tbqSize @@ -216,11 +217,7 @@ processChatCommand = \case StartChat subConns -> withUser' $ \user -> asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning - _ -> - ifM - (asks chatStoreChanged >>= readTVarIO) - (throwChatError CEChatStoreChanged) - (startChatController user subConns $> CRChatStarted) + _ -> checkStoreNotChanged $ startChatController user subConns $> CRChatStarted APIStopChat -> do ask >>= stopChatController pure CRChatStopped @@ -239,8 +236,9 @@ processChatCommand = \case atomically . writeTVar incognito $ onOff pure CRCmdOk APIExportArchive cfg -> checkChatStopped $ exportArchive cfg $> CRCmdOk - APIImportArchive cfg -> checkChatStopped $ importArchive cfg >> setStoreChanged $> CRCmdOk - APIDeleteStorage -> checkChatStopped $ deleteStorage >> setStoreChanged $> CRCmdOk + APIImportArchive cfg -> withStoreChanged $ importArchive cfg + APIDeleteStorage -> withStoreChanged $ deleteStorage + APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg APIGetChats withPCC -> CRApiChats <$> withUser (\user -> withStore' $ \db -> getChatPreviews db user withPCC) APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\db -> getDirectChat db user cId pagination search) @@ -947,6 +945,10 @@ processChatCommand = \case checkChatStopped a = asks agentAsync >>= readTVarIO >>= maybe a (const $ throwChatError CEChatNotStopped) setStoreChanged :: m () setStoreChanged = asks chatStoreChanged >>= atomically . (`writeTVar` True) + withStoreChanged :: m () -> m ChatResponse + withStoreChanged a = checkChatStopped $ a >> setStoreChanged $> CRCmdOk + checkStoreNotChanged :: m ChatResponse -> m ChatResponse + checkStoreNotChanged = ifM (asks chatStoreChanged >>= readTVarIO) (throwChatError CEChatStoreChanged) getSentChatItemIdByText :: User -> ChatRef -> ByteString -> m Int64 getSentChatItemIdByText user@User {userId, localDisplayName} (ChatRef cType cId) msg = case cType of CTDirect -> withStore $ \db -> getDirectChatItemIdByText db userId cId SMDSnd (safeDecodeUtf8 msg) @@ -2635,6 +2637,10 @@ chatCommandP = "/_db export " *> (APIExportArchive <$> jsonP), "/_db import " *> (APIImportArchive <$> jsonP), "/_db delete" $> APIDeleteStorage, + "/_db encryption " *> (APIStorageEncryption <$> jsonP), + "/db encrypt " *> (APIStorageEncryption . DBEncryptionConfig "" <$> dbKeyP), + "/db key " *> (APIStorageEncryption <$> (DBEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)), + "/db decrypt " *> (APIStorageEncryption . (`DBEncryptionConfig` "") <$> dbKeyP), "/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)), "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional searchP), "/_get items count=" *> (APIGetChatItems <$> A.decimal), @@ -2784,6 +2790,8 @@ chatCommandP = t_ <- optional $ " timeout=" *> A.decimal let tcpTimeout = 1000000 * fromMaybe (maybe 5 (const 10) socksProxy) t_ pure $ fullNetworkConfig socksProxy tcpTimeout + dbKeyP = nonEmptyKey <$?> strP + nonEmptyKey k@(DBEncryptionKey s) = if null s then Left "empty key" else Right k adminContactReq :: ConnReqContact adminContactReq = diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 8f7a92e675..f4bcc5ca46 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -1,16 +1,30 @@ {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} -module Simplex.Chat.Archive where +module Simplex.Chat.Archive + ( exportArchive, + importArchive, + deleteStorage, + sqlCipherExport, + ) +where import qualified Codec.Archive.Zip as Z +import Control.Monad.Except import Control.Monad.Reader +import Data.Functor (($>)) +import qualified Data.Text as T +import qualified Database.SQLite3 as SQL import Simplex.Chat.Controller -import Simplex.Messaging.Agent.Client (agentDbPath) -import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..)) -import Simplex.Messaging.Util (whenM) +import Simplex.Messaging.Agent.Client (agentStore) +import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), sqlString) +import Simplex.Messaging.Util (unlessM, whenM) import System.FilePath import UnliftIO.Directory +import UnliftIO.Exception (SomeException, bracket, catch) import UnliftIO.STM import UnliftIO.Temporary @@ -73,14 +87,66 @@ deleteStorage = do data StorageFiles = StorageFiles { chatDb :: FilePath, + chatEncrypted :: TVar Bool, agentDb :: FilePath, + agentEncrypted :: TVar Bool, filesPath :: Maybe FilePath } storageFiles :: ChatMonad m => m StorageFiles storageFiles = do ChatController {chatStore, filesFolder, smpAgent} <- ask - let SQLiteStore {dbFilePath = chatDb} = chatStore - agentDb = agentDbPath smpAgent + let SQLiteStore {dbFilePath = chatDb, dbEncrypted = chatEncrypted} = chatStore + SQLiteStore {dbFilePath = agentDb, dbEncrypted = agentEncrypted} = agentStore smpAgent filesPath <- readTVarIO filesFolder - pure StorageFiles {chatDb, agentDb, filesPath} + pure StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted, filesPath} + +sqlCipherExport :: forall m. ChatMonad m => DBEncryptionConfig -> m () +sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = DBEncryptionKey key'} = + when (key /= key') $ do + fs@StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted} <- storageFiles + checkFile `with` fs + backup `with` fs + (export chatDb chatEncrypted >> export agentDb agentEncrypted) + `catchError` \e -> (restore `with` fs) >> throwError e + where + action `with` StorageFiles {chatDb, agentDb} = action chatDb >> action agentDb + backup f = copyFile f (f <> ".bak") + restore f = copyFile (f <> ".bak") f + checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f + export f dbEnc = do + enc <- readTVarIO dbEnc + when (enc && null key) $ throwDBError DBErrorEncrypted + when (not enc && not (null key)) $ throwDBError DBErrorPlaintext + withDB (`SQL.exec` exportSQL) DBErrorExport + renameFile (f <> ".exported") f + withDB (`SQL.exec` testSQL) DBErrorOpen + atomically $ writeTVar dbEnc $ not (null key') + where + withDB a err = + liftIO (bracket (SQL.open $ T.pack f) SQL.close a $> Nothing) + `catch` checkSQLError + `catch` (\(e :: SomeException) -> sqliteError' e) + >>= mapM_ (throwDBError . err) + where + checkSQLError e = case SQL.sqlError e of + SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase + _ -> sqliteError' e + sqliteError' :: Show e => e -> m (Maybe SQLiteError) + sqliteError' = pure . Just . SQLiteError . show + exportSQL = + T.unlines $ + keySQL key + <> [ "ATTACH DATABASE " <> sqlString (f <> ".exported") <> " AS exported KEY " <> sqlString key' <> ";", + "SELECT sqlcipher_export('exported');", + "DETACH DATABASE exported;" + ] + testSQL = + T.unlines $ + keySQL key' + <> [ "PRAGMA foreign_keys = ON;", + "PRAGMA secure_delete = ON;", + "PRAGMA auto_vacuum = FULL;", + "SELECT count(*) FROM sqlite_master;" + ] + keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)] diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c982609017..b477376cac 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -17,9 +17,13 @@ import Control.Monad.Reader import Crypto.Random (ChaChaDRG) import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Char (ord) import Data.Int (Int64) import Data.Map.Strict (Map) +import Data.String import Data.Text (Text) import Data.Time (ZonedTime) import Data.Time.Clock (UTCTime) @@ -38,8 +42,9 @@ import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, InitialAgentServers, Net import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) import Simplex.Messaging.Protocol (AProtocolType, CorrId, MsgFlags) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport.Client (TransportHost) @@ -112,6 +117,7 @@ data ChatCommand | APIExportArchive ArchiveConfig | APIImportArchive ArchiveConfig | APIDeleteStorage + | APIStorageEncryption DBEncryptionConfig | APIGetChats {pendingConnections :: Bool} | APIGetChat ChatRef ChatPagination (Maybe String) | APIGetChatItems Int @@ -323,6 +329,21 @@ instance ToJSON ChatResponse where data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath} deriving (Show, Generic, FromJSON) +data DBEncryptionConfig = DBEncryptionConfig {currentKey :: DBEncryptionKey, newKey :: DBEncryptionKey} + deriving (Show, Generic, FromJSON) + +newtype DBEncryptionKey = DBEncryptionKey String + deriving (Show) + +instance IsString DBEncryptionKey where fromString = parseString $ parseAll strP + +instance StrEncoding DBEncryptionKey where + strEncode (DBEncryptionKey s) = B.pack s + strP = DBEncryptionKey . B.unpack <$> A.takeWhile (\c -> c /= ' ' && ord c >= 0x21 && ord c <= 0x7E) + +instance FromJSON DBEncryptionKey where + parseJSON = strParseJSON "DBEncryptionKey" + data ContactSubStatus = ContactSubStatus { contact :: Contact, contactError :: Maybe ChatError @@ -372,6 +393,7 @@ data ChatError = ChatError {errorType :: ChatErrorType} | ChatErrorAgent {agentError :: AgentErrorType} | ChatErrorStore {storeError :: StoreError} + | ChatErrorDatabase {databaseError :: DatabaseError} deriving (Show, Exception, Generic) instance ToJSON ChatError where @@ -430,6 +452,28 @@ instance ToJSON ChatErrorType where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CE" +data DatabaseError + = DBErrorEncrypted + | DBErrorPlaintext + | DBErrorNoFile {dbFile :: String} + | DBErrorExport {sqliteError :: SQLiteError} + | DBErrorOpen {sqliteError :: SQLiteError} + deriving (Show, Exception, Generic) + +instance ToJSON DatabaseError where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "DB" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "DB" + +data SQLiteError = SQLiteErrorNotADatabase | SQLiteError String + deriving (Show, Exception, Generic) + +instance ToJSON SQLiteError where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SQLite" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SQLite" + +throwDBError :: ChatMonad m => DatabaseError -> m () +throwDBError = throwError . ChatErrorDatabase + type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m) chatCmdError :: String -> ChatResponse diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 597b524e27..13b77b2023 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -23,7 +23,7 @@ simplexChatCore cfg@ChatConfig {yesToMigrations} opts sendToast chat where initRun = do let f = chatStoreFile $ dbFilePrefix opts - st <- createStore f yesToMigrations + st <- createChatStore f (dbKey opts) yesToMigrations u <- getCreateActiveUser st cc <- newChatController st (Just u) cfg opts sendToast runSimplexChat opts u cc chat diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 5e862f315c..0e3d68d7a2 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -1,18 +1,23 @@ {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Mobile where import Control.Concurrent.STM +import Control.Exception (catch) import Control.Monad.Reader import Data.Aeson (ToJSON (..)) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Functor (($>)) import Data.List (find) import Data.Maybe (fromMaybe) +import Database.SQLite.Simple (SQLError (..)) +import qualified Database.SQLite.Simple as DB import Foreign.C.String import Foreign.C.Types (CInt (..)) import Foreign.StablePtr @@ -24,13 +29,21 @@ import Simplex.Chat.Options import Simplex.Chat.Store import Simplex.Chat.Types import Simplex.Chat.Util (safeDecodeUtf8) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (yesToMigrations)) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (yesToMigrations), createAgentStore) +import Simplex.Messaging.Agent.Store.SQLite (closeSQLiteStore) import Simplex.Messaging.Client (defaultNetworkConfig) +import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (CorrId (..)) +import Simplex.Messaging.Util (catchAll) import System.Timeout (timeout) +foreign export ccall "chat_migrate_db" cChatMigrateDB :: CString -> CString -> IO CJSONString + +-- chat_init is deprecated foreign export ccall "chat_init" cChatInit :: CString -> IO (StablePtr ChatController) +foreign export ccall "chat_init_key" cChatInitKey :: CString -> CString -> IO (StablePtr ChatController) + foreign export ccall "chat_send_cmd" cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> IO CJSONString @@ -39,11 +52,23 @@ foreign export ccall "chat_recv_msg_wait" cChatRecvMsgWait :: StablePtr ChatCont foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO CJSONString --- | initialize chat controller +-- | check and migrate the database +-- This function validates that the encryption is correct and runs migrations - it should be called before cChatInitKey +cChatMigrateDB :: CString -> CString -> IO CJSONString +cChatMigrateDB fp key = + ((,) <$> peekCAString fp <*> peekCAString key) >>= uncurry chatMigrateDB >>= newCAString . LB.unpack . J.encode + +-- | initialize chat controller (deprecated) -- The active user has to be created and the chat has to be started before most commands can be used. cChatInit :: CString -> IO (StablePtr ChatController) cChatInit fp = peekCAString fp >>= chatInit >>= newStablePtr +-- | initialize chat controller with encrypted database +-- The active user has to be created and the chat has to be started before most commands can be used. +cChatInitKey :: CString -> CString -> IO (StablePtr ChatController) +cChatInitKey fp key = + ((,) <$> peekCAString fp <*> peekCAString key) >>= uncurry chatInitKey >>= newStablePtr + -- | send command to chat (same syntax as in terminal for now) cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString cChatSendCmd cPtr cCmd = do @@ -67,6 +92,7 @@ mobileChatOpts :: ChatOpts mobileChatOpts = ChatOpts { dbFilePrefix = undefined, + dbKey = "", smpServers = [], networkConfig = defaultNetworkConfig, logConnections = False, @@ -90,12 +116,41 @@ type CJSONString = CString getActiveUser_ :: SQLiteStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> withTransaction st getUsers +data DBMigrationResult + = DBMOk + | DBMErrorNotADatabase {dbFile :: String} + | DBMError {dbFile :: String, migrationError :: String} + deriving (Show, Generic) + +instance ToJSON DBMigrationResult where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "DBM" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "DBM" + +chatMigrateDB :: String -> String -> IO DBMigrationResult +chatMigrateDB dbFilePrefix dbKey = + migrate createChatStore (chatStoreFile dbFilePrefix) >>= \case + DBMOk -> migrate createAgentStore (agentStoreFile dbFilePrefix) + e -> pure e + where + migrate createStore dbFile = + ((createStore dbFile dbKey True >>= closeSQLiteStore) $> DBMOk) + `catch` (pure . checkDBError) + `catchAll` (pure . dbError) + where + checkDBError e = case sqlError e of + DB.ErrorNotADatabase -> DBMErrorNotADatabase dbFile + _ -> dbError e + dbError e = DBMError dbFile $ show e + chatInit :: String -> IO ChatController -chatInit dbFilePrefix = do +chatInit = (`chatInitKey` "") + +chatInitKey :: String -> String -> IO ChatController +chatInitKey dbFilePrefix dbKey = do let f = chatStoreFile dbFilePrefix - chatStore <- createStore f (yesToMigrations (defaultMobileConfig :: ChatConfig)) + chatStore <- createChatStore f dbKey (yesToMigrations (defaultMobileConfig :: ChatConfig)) user_ <- getActiveUser_ chatStore - newChatController chatStore user_ defaultMobileConfig mobileChatOpts {dbFilePrefix} Nothing + newChatController chatStore user_ defaultMobileConfig mobileChatOpts {dbFilePrefix, dbKey} Nothing chatSendCmd :: ChatController -> String -> IO JSONString chatSendCmd cc s = LB.unpack . J.encode . APIResponse Nothing <$> runReaderT (execChatCommand $ B.pack s) cc diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 24f809a4a1..8f2bbfd0f5 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -25,6 +25,7 @@ import System.FilePath (combine) data ChatOpts = ChatOpts { dbFilePrefix :: String, + dbKey :: String, smpServers :: [SMPServer], networkConfig :: NetworkConfig, logConnections :: Bool, @@ -47,6 +48,14 @@ chatOpts appDir defaultDbFileName = do <> value defaultDbFilePath <> showDefault ) + dbKey <- + strOption + ( long "key" + <> short 'k' + <> metavar "KEY" + <> help "Database encryption key/pass-phrase" + <> value "" + ) smpServers <- option parseSMPServers @@ -126,6 +135,7 @@ chatOpts appDir defaultDbFileName = do pure ChatOpts { dbFilePrefix, + dbKey, smpServers, networkConfig = fullNetworkConfig socksProxy $ useTcpTimeout socksProxy t, logConnections, diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 6a6460cc77..e9e8c826ea 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -20,8 +20,9 @@ module Simplex.Chat.Store ( SQLiteStore, StoreError (..), - createStore, + createChatStore, chatStoreFile, + agentStoreFile, createUser, getUsers, setActiveUser, @@ -287,12 +288,15 @@ migrations = sortBy (compare `on` name) $ map migration schemaMigrations where migration (name, query) = Migration {name = name, up = fromQuery query} -createStore :: FilePath -> Bool -> IO SQLiteStore -createStore dbFilePath = createSQLiteStore dbFilePath migrations +createChatStore :: FilePath -> String -> Bool -> IO SQLiteStore +createChatStore dbFilePath dbKey = createSQLiteStore dbFilePath dbKey migrations chatStoreFile :: FilePath -> FilePath chatStoreFile = (<> "_chat.db") +agentStoreFile :: FilePath -> FilePath +agentStoreFile = (<> "_agent.db") + checkConstraint :: StoreError -> ExceptT StoreError IO a -> ExceptT StoreError IO a checkConstraint err action = ExceptT $ runExceptT action `E.catch` (pure . Left . handleSQLError err) diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 66a35fc138..df152df312 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -5,8 +5,11 @@ module Simplex.Chat.Terminal where +import Control.Exception (handle, throwIO) import Control.Monad.Except import qualified Data.List.NonEmpty as L +import Database.SQLite.Simple (SQLError (..)) +import qualified Database.SQLite.Simple as DB import Simplex.Chat (defaultChatConfig) import Simplex.Chat.Controller import Simplex.Chat.Core @@ -18,6 +21,7 @@ import Simplex.Chat.Terminal.Output import Simplex.Messaging.Agent.Env.SQLite (InitialAgentServers (..)) import Simplex.Messaging.Client (defaultNetworkConfig) import Simplex.Messaging.Util (raceAny_) +import System.Exit (exitFailure) terminalChatConfig :: ChatConfig terminalChatConfig = @@ -38,10 +42,17 @@ terminalChatConfig = simplexChatTerminal :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () simplexChatTerminal cfg opts t = do sendToast <- initializeNotifications - simplexChatCore cfg opts (Just sendToast) $ \u cc -> do + handle checkDBKeyError . simplexChatCore cfg opts (Just sendToast) $ \u cc -> do ct <- newChatTerminal t when (firstTime cc) . printToTerminal ct $ chatWelcome u runChatTerminal ct cc +checkDBKeyError :: SQLError -> IO () +checkDBKeyError e = case sqlError e of + DB.ErrorNotADatabase -> do + putStrLn "Database file is invalid or you passed an incorrect encryption key" + exitFailure + _ -> throwIO e + runChatTerminal :: ChatTerminal -> ChatController -> IO () runChatTerminal ct cc = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc, runInputLoop ct cc] diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 25e0ff968b..f9e5ef4ad1 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -886,7 +886,7 @@ viewChatError = \case CEActiveUserExists -> ["error: active user already exists"] CEChatNotStarted -> ["error: chat not started"] CEChatNotStopped -> ["error: chat not stopped"] - CEChatStoreChanged -> ["error: chat store changed"] + CEChatStoreChanged -> ["error: chat store changed, please restart chat"] CEInvalidConnReq -> viewInvalidConnReq CEInvalidChatMessage e -> ["chat message error: " <> sShow e] CEContactNotReady c -> [ttyContact' c <> ": not ready"] @@ -944,6 +944,12 @@ viewChatError = \case SEConnectionNotFound _ -> [] -- TODO mutes delete group error, but also mutes any error from getConnectionEntity SEQuotedChatItemNotFound -> ["message not found - reply is not sent"] e -> ["chat db error: " <> sShow e] + ChatErrorDatabase err -> case err of + DBErrorEncrypted -> ["error: chat database is already encrypted"] + DBErrorPlaintext -> ["error: chat database is not encrypted"] + DBErrorExport e -> ["error encrypting database: " <> sqliteError' e] + DBErrorOpen e -> ["error opening database after encryption: " <> sqliteError' e] + e -> ["chat database error: " <> sShow e] ChatErrorAgent err -> case err of SMP SMP.AUTH -> [ "error: connection authorization failed - this could happen if connection was deleted,\ @@ -955,6 +961,9 @@ viewChatError = \case e -> ["smp agent error: " <> sShow e] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] + sqliteError' = \case + SQLiteErrorNotADatabase -> "wrong passphrase or invalid database file" + SQLiteError e -> sShow e ttyContact :: ContactName -> StyledString ttyContact = styled $ colored Green diff --git a/stack.yaml b/stack.yaml index a9aff4fe70..3d07ce843c 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,13 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: e328ae5d060645a8ef090b1b3d88bc20a5902e45 + commit: f08d81252deb68eb4a1ce4502712b99264b6749d + # - ../direct-sqlcipher + - github: simplex-chat/direct-sqlcipher + commit: 34309410eb2069b029b8fc1872deb1e0db123294 + # - ../sqlcipher-simple + - github: simplex-chat/sqlcipher-simple + commit: 5e154a2aeccc33ead6c243ec07195ab673137221 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 @@ -63,6 +69,8 @@ flags: zip: disable-bzip2: true disable-zstd: true + direct-sqlcipher: + openssl: true # Extra package databases containing global packages # extra-package-dbs: [] @@ -78,7 +86,6 @@ flags: # arch: x86_64 # # Extra directories used by stack for building -# extra-include-dirs: [/path/to/dir] # extra-lib-dirs: [/path/to/dir] # # Allow a newer minor version of GHC than the snapshot specifies diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index e1ce5737d3..c2329e4ab4 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -48,6 +48,8 @@ testOpts :: ChatOpts testOpts = ChatOpts { dbFilePrefix = undefined, + dbKey = "", + -- dbKey = "this is a pass-phrase to encrypt the database", smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"], networkConfig = defaultNetworkConfig, logConnections = False, @@ -101,16 +103,16 @@ testCfgV1 :: ChatConfig testCfgV1 = testCfg {agentConfig = testAgentCfgV1} createTestChat :: ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC -createTestChat cfg opts dbPrefix profile = do +createTestChat cfg opts@ChatOpts {dbKey} dbPrefix profile = do let dbFilePrefix = testDBPrefix <> dbPrefix - st <- createStore (dbFilePrefix <> "_chat.db") False + st <- createChatStore (dbFilePrefix <> "_chat.db") dbKey False Right user <- withTransaction st $ \db -> runExceptT $ createUser db profile True startTestChat_ st cfg opts dbFilePrefix user startTestChat :: ChatConfig -> ChatOpts -> String -> IO TestCC -startTestChat cfg opts dbPrefix = do +startTestChat cfg opts@ChatOpts {dbKey} dbPrefix = do let dbFilePrefix = testDBPrefix <> dbPrefix - st <- createStore (dbFilePrefix <> "_chat.db") False + st <- createChatStore (dbFilePrefix <> "_chat.db") dbKey False Just user <- find activeUser <$> withTransaction st getUsers startTestChat_ st cfg opts dbFilePrefix user diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index d6625f8ff5..1325db5620 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -115,6 +115,7 @@ chatTests = do describe "maintenance mode" $ do it "start/stop/export/import chat" testMaintenanceMode it "export/import chat with files" testMaintenanceModeWithFiles + it "encrypt/decrypt database" testDatabaseEncryption describe "mute/unmute messages" $ do it "mute/unmute contact" testMuteContact it "mute/unmute group" testMuteGroup @@ -2717,7 +2718,7 @@ testMaintenanceMode = withTmpFiles $ do alice <## "ok" -- cannot start chat after import alice ##> "/_start" - alice <## "error: chat store changed" + alice <## "error: chat store changed, please restart chat" -- works after full restart withTestChat "alice" $ \alice -> testChatWorking alice bob @@ -2752,7 +2753,7 @@ testMaintenanceModeWithFiles = withTmpFiles $ do alice <## "ok" -- cannot start chat after delete alice ##> "/_start" - alice <## "error: chat store changed" + alice <## "error: chat store changed, please restart chat" doesDirectoryExist "./tests/tmp/alice_files" `shouldReturn` False alice ##> "/_db import {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" alice <## "ok" @@ -2760,6 +2761,49 @@ testMaintenanceModeWithFiles = withTmpFiles $ do -- works after full restart withTestChat "alice" $ \alice -> testChatWorking alice bob +testDatabaseEncryption :: IO () +testDatabaseEncryption = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChatOpts testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do + alice ##> "/_start" + alice <## "chat started" + connectUsers alice bob + alice #> "@bob hi" + bob <# "alice> hi" + alice ##> "/db encrypt mykey" + alice <## "error: chat not stopped" + alice ##> "/db decrypt mykey" + alice <## "error: chat not stopped" + alice ##> "/_stop" + alice <## "chat stopped" + alice ##> "/db decrypt mykey" + alice <## "error: chat database is not encrypted" + alice ##> "/db encrypt mykey" + alice <## "ok" + alice ##> "/_start" + alice <## "error: chat store changed, please restart chat" + withTestChatOpts testOpts {maintenance = True, dbKey = "mykey"} "alice" $ \alice -> do + alice ##> "/_start" + alice <## "chat started" + testChatWorking alice bob + alice ##> "/_stop" + alice <## "chat stopped" + alice ##> "/db key wrongkey nextkey" + alice <## "error encrypting database: wrong passphrase or invalid database file" + alice ##> "/db key mykey nextkey" + alice <## "ok" + alice ##> "/_db encryption {\"currentKey\":\"nextkey\",\"newKey\":\"anotherkey\"}" + alice <## "ok" + withTestChatOpts testOpts {maintenance = True, dbKey = "anotherkey"} "alice" $ \alice -> do + alice ##> "/_start" + alice <## "chat started" + testChatWorking alice bob + alice ##> "/_stop" + alice <## "chat stopped" + alice ##> "/db decrypt anotherkey" + alice <## "ok" + withTestChat "alice" $ \alice -> testChatWorking alice bob + testMuteContact :: IO () testMuteContact = testChat2 aliceProfile bobProfile $ diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 68057b5dc4..a7d4aaa70a 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -73,7 +73,9 @@ parsedMarkdown = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\ testChatApiNoUser :: IO () testChatApiNoUser = withTmpFiles $ do + DBMOk <- chatMigrateDB testDBPrefix "" cc <- chatInit testDBPrefix + DBMErrorNotADatabase _ <- chatMigrateDB testDBPrefix "myKey" chatSendCmd cc "/u" `shouldReturn` noActiveUser chatSendCmd cc "/_start" `shouldReturn` noActiveUser chatSendCmd cc "/u alice Alice" `shouldReturn` activeUser @@ -81,10 +83,14 @@ testChatApiNoUser = withTmpFiles $ do testChatApi :: IO () testChatApi = withTmpFiles $ do - let f = chatStoreFile $ testDBPrefix <> "1" - st <- createStore f True + let dbPrefix = testDBPrefix <> "1" + f = chatStoreFile dbPrefix + st <- createChatStore f "myKey" True Right _ <- withTransaction st $ \db -> runExceptT $ createUser db aliceProfile True - cc <- chatInit $ testDBPrefix <> "1" + DBMOk <- chatMigrateDB testDBPrefix "myKey" + cc <- chatInitKey dbPrefix "myKey" + DBMErrorNotADatabase _ <- chatMigrateDB testDBPrefix "" + DBMErrorNotADatabase _ <- chatMigrateDB testDBPrefix "anotherKey" chatSendCmd cc "/u" `shouldReturn` activeUser chatSendCmd cc "/u alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 923d735314..808e1773a4 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -5,7 +5,7 @@ module SchemaDump where import ChatClient (withTmpFiles) import Control.DeepSeq import Control.Monad (void) -import Simplex.Chat.Store (createStore) +import Simplex.Chat.Store (createChatStore) import System.Process (readCreateProcess, shell) import Test.Hspec @@ -22,7 +22,7 @@ schemaDumpTest = testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do - void $ createStore testDB False + void $ createChatStore testDB "" False void $ readCreateProcess (shell $ "touch " <> schema) "" savedSchema <- readFile schema savedSchema `deepseq` pure ()