mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-28 12:19:54 +00:00
* core: pass event and response error without dedicated constructor * ios: WIP * android, desktop: update UI for new API * ios: fix parser * fix showing invalid chats * fix mobile api tests * ios: split ChatResponse to 3 enums, decode API results on the same thread * tweak types * remove throws * rename
393 lines
15 KiB
Swift
393 lines
15 KiB
Swift
//
|
|
// 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<Bool>, @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 })
|
|
}
|
|
}
|