// // 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: LocalizedStringKey = "") 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 @EnvironmentObject private var theme: AppTheme @Binding var useKeychain: Bool var migration: 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 = kcDatabasePassword.get() != nil @State private var currentKey = "" @State private var newKey = "" @State private var confirmNewKey = "" @State private var currentKeyShown = false let stopChatRunBlockStartChat: (Binding, @escaping () async throws -> Bool) -> Void var body: some View { ZStack { List { if migration { chatStoppedView() } databaseEncryptionView() } if progressIndicator { ProgressView().scaleEffect(2) } } } private func databaseEncryptionView() -> some View { Section { settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : theme.colors.secondary) { Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle) .onChange(of: useKeychainToggle) { _ in if useKeychainToggle { setUseKeychain(true) } else if storedKey && !migration { // Don't show in migration process since it will remove the key after successfull encryption alert = .keychainRemoveKey } else { setUseKeychain(false) } } .disabled(initialRandomDBPassphrase && !migration) } if !initialRandomDBPassphrase && m.chatDbEncrypted == true { PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey)) } PassphraseField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true) PassphraseField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey) settingsRow("lock.rotation", color: theme.colors.secondary) { Button(migration ? "Set passphrase" : "Update database passphrase") { alert = currentKey == "" ? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase) : (useKeychain ? .changeDatabaseKeySaved : .changeDatabaseKey) } } .disabled( (m.chatDbEncrypted == true && currentKey == "") || currentKey == newKey || newKey != confirmNewKey || newKey == "" || !validKey(currentKey) || !validKey(newKey) ) } header: { Text(migration ? "Database passphrase" : "") .foregroundColor(theme.colors.secondary) } 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 && !migration { 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 && !migration { Text("**Warning**: Instant push notifications require passphrase saved in Keychain.") } } } .foregroundColor(theme.colors.secondary) .padding(.top, 1) .font(.callout) } .onAppear { if initialRandomDBPassphrase { currentKey = kcDatabasePassword.get() ?? "" } } .disabled(progressIndicator) .alert(item: $alert) { item in databaseEncryptionAlert(item) } } private func encryptDatabaseAsync() async -> Bool { await MainActor.run { progressIndicator = true } do { encryptionStartedDefault.set(true) encryptionStartedAtDefault.set(Date.now) if !m.chatDbChanged { try apiSaveAppSettings(settings: AppSettings.current.prepareForExport()) } try await apiStorageEncryption(currentKey: currentKey, newKey: newKey) encryptionStartedDefault.set(false) initialRandomDBPassphraseGroupDefault.set(false) if migration { storeDBPassphraseGroupDefault.set(useKeychain) } if useKeychain { if kcDatabasePassword.set(newKey) { await resetFormAfterEncryption(true) await operationEnded(.databaseEncrypted) } else { await resetFormAfterEncryption() await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain")) } } else { if migration { removePassphraseFromKeyChain() } await resetFormAfterEncryption() await operationEnded(.databaseEncrypted) } return true } catch let error { if case .errorDatabase(.errorExport(.errorNotADatabase)) = error as? ChatError { await operationEnded(.currentPassphraseError) } else { await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))")) } return false } } private func encryptDatabase() { // it will try to stop and start the chat in case of: non-migration && successful encryption. In migration the chat will remain stopped if migration { Task { await encryptDatabaseAsync() } } else { stopChatRunBlockStartChat($progressIndicator) { return await encryptDatabaseAsync() } } } 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 // Postpone it when migrating to the end of encryption process if !migration { 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")) { removePassphraseFromKeyChain() }, 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 removePassphraseFromKeyChain() { if kcDatabasePassword.remove() { logger.debug("passphrase removed from keychain") setUseKeychain(false) storedKey = false } else { alert = .error(title: "Keychain error", error: "Failed to remove passphrase") } } 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 m.chatInitialized = false progressIndicator = false alert = dbAlert } } } struct PassphraseField: View { @EnvironmentObject var theme: AppTheme @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 : theme.colors.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 > 100 ? .strong : enthropy > 70 ? .reasonable : enthropy > 40 ? .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), migration: false, stopChatRunBlockStartChat: { _, _ in true }) } }