From 766009269e8809713fab8a973386642b254dc15c Mon Sep 17 00:00:00 2001
From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Date: Wed, 7 Sep 2022 12:49:41 +0100
Subject: [PATCH] ios: use SQLCipher (#1012)
* use sqlcipher build, hardcoded encryption key
* UI for db encryption
* database passphrase UI
* show orange icon when database is not encrypted
* call encrypt
* more ios ux
* basic UX for passphrase complete
* with animation
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
* passphrase complexity, fixes
* fix moving entry field
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
---
apps/ios/Shared/ContentView.swift | 3 +
apps/ios/Shared/Model/ChatModel.swift | 2 +
apps/ios/Shared/Model/SimpleXAPI.swift | 35 +-
apps/ios/Shared/Model/SuspendChat.swift | 4 +-
apps/ios/Shared/SimpleXApp.swift | 2 +-
.../Database/DatabaseEncryptionView.swift | 343 ++++++++++++++++++
.../Views/Database/DatabaseErrorView.swift | 93 +++++
.../Shared/Views/Database/DatabaseView.swift | 42 ++-
.../Views/UserSettings/SettingsView.swift | 7 +-
apps/ios/SimpleX (iOS).entitlements | 4 +
apps/ios/SimpleX NSE/SimpleX NSE.entitlements | 4 +
apps/ios/SimpleX.xcodeproj/project.pbxproj | 52 ++-
apps/ios/SimpleXChat/API.swift | 59 ++-
apps/ios/SimpleXChat/APITypes.swift | 39 ++
apps/ios/SimpleXChat/AppGroup.swift | 10 +-
apps/ios/SimpleXChat/FileUtils.swift | 10 +-
apps/ios/SimpleXChat/KeyChain.swift | 107 ++++++
apps/ios/SimpleXChat/SimpleX.h | 3 +-
18 files changed, 756 insertions(+), 63 deletions(-)
create mode 100644 apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift
create mode 100644 apps/ios/Shared/Views/Database/DatabaseErrorView.swift
create mode 100644 apps/ios/SimpleXChat/KeyChain.swift
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..d349294a0f 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
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..33ad5af8ed 100644
--- a/apps/ios/Shared/Model/SuspendChat.swift
+++ b/apps/ios/Shared/Model/SuspendChat.swift
@@ -71,9 +71,9 @@ private func _chatSuspended() {
}
}
-func activateChat(appState: AppState = .active) {
+func activateChat(appState: AppState = .active, databaseReady: Bool = true) {
suspendLockQueue.sync {
appStateGroupDefault.set(appState)
- apiActivateChat()
+ if databaseReady { apiActivateChat() }
}
}
diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift
index 386ae0c431..6061e7ce52 100644
--- a/apps/ios/Shared/SimpleXApp.swift
+++ b/apps/ios/Shared/SimpleXApp.swift
@@ -64,7 +64,7 @@ struct SimpleXApp: App {
ChatReceiver.shared.start()
}
let appState = appStateGroupDefault.get()
- activateChat()
+ activateChat(databaseReady: chatModel.chatDbStatus == .ok)
if appState.inactive && chatModel.chatRunning == true {
updateChats()
updateCallInvitations()
diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift
new file mode 100644
index 0000000000..374133355b
--- /dev/null
+++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift
@@ -0,0 +1,343 @@
+//
+// 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
+ @State private var alert: DatabaseEncryptionAlert? = nil
+ @State private var progressIndicator = false
+ @State private var useKeychain = storeDBPassphraseGroupDefault.get()
+ @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("key") {
+ 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 passsphrase!"),
+ 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
+ @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)
+ }
+ }
+
+ @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()
+ }
+}
diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift
new file mode 100644
index 0000000000..310c0d96ea
--- /dev/null
+++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift
@@ -0,0 +1,93 @@
+//
+// 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
+ 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(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey))
+ saveAndOpenButton()
+ Spacer()
+ Text("File: \(dbFile)")
+ } else {
+ Text("Encrypted database").font(.title)
+ Text("Database passphrase is required to open chat.")
+ DatabaseKeyField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey))
+ if useKeychain {
+ saveAndOpenButton()
+ } else {
+ 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 saveAndOpenButton() -> some View {
+ Button("Save passphrase and open chat") {
+ if setDatabaseKey(dbKey) {
+ storeDBPassphraseGroupDefault.set(true)
+ initialRandomDBPassphraseGroupDefault.set(false)
+ }
+ do {
+ try initializeChat(start: m.v3DBMigration.startChat, dbKey: dbKey)
+ } catch let error {
+ logger.error("initializeChat \(responseError(error))")
+ }
+ }
+ }
+
+ private func openChatButton() -> some View {
+ Button("Open chat") {
+ do {
+ try initializeChat(start: m.v3DBMigration.startChat, dbKey: dbKey)
+ } 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..8f7ba7cca4 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"
@@ -82,18 +84,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" : "lock", color: color) {
+ NavigationLink {
+ DatabaseEncryptionView()
+ .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 +122,8 @@ struct DatabaseView: View {
}
}
settingsRow("trash.slash") {
- Button(role: .destructive) {
+ Button("Delete database", role: .destructive) {
alert = .deleteChat
- } label: {
- Text("Delete database")
}
}
} header: {
@@ -130,10 +140,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")
}
}
}
@@ -166,6 +174,11 @@ struct DatabaseView: View {
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(
@@ -254,6 +267,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 +287,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/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 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.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj
index 060d05d5f9..46cfbb2b8c 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,8 @@
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 = ""; };
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 +357,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 +418,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 +602,7 @@
5CDCAD7D2818941F00503DA2 /* API.swift */,
5CDCAD80281A7E2700503DA2 /* Notifications.swift */,
64DAE1502809D9F5000DA960 /* FileUtils.swift */,
+ 5C00168028C4FE760094D739 /* KeyChain.swift */,
5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */,
5CE2BA8A2845332200EC33A6 /* SimpleX.h */,
5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */,
@@ -642,6 +649,8 @@
5C4B3B09285FB130003915F2 /* DatabaseView.swift */,
5CFA59CF286477B400863A68 /* ChatArchiveView.swift */,
5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */,
+ 5C9CC7A828C532AB00BEF955 /* DatabaseErrorView.swift */,
+ 5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */,
);
path = Database;
sourceTree = "";
@@ -858,6 +867,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 +944,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 +973,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 */,
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/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/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);