// // OmemoKeys.swift // Monal // // Created by Jan on 04.05.22. // Copyright © 2022 Monal.im. All rights reserved. // import OrderedCollections struct OmemoKeysEntryView: View { private let contactJid: String @State private var trustLevel: NSNumber @State private var showEntryInfo = false @State private var showClipboardCopy = false private let deviceId: NSNumber private let fingerprint: Data private let address: SignalAddress private let account: xmpp private let isOwnDevice: Bool private let isBrokenSession: Bool init(account: xmpp, contactJid: String, deviceId: NSNumber, isOwnDevice: Bool) { self.contactJid = contactJid self.deviceId = deviceId self.isOwnDevice = isOwnDevice self.address = SignalAddress.init(name: contactJid, deviceId: Int32(deviceId.int32Value)) self.fingerprint = account.omemo.getIdentityFor(self.address) self.trustLevel = account.omemo.getTrustLevel(self.address, identityKey: self.fingerprint) self.account = account self.isBrokenSession = account.omemo.isSessionBroken(forJid:contactJid, andDeviceId:deviceId) } func setTrustLevel(_ enableTrust: Bool) { self.account.omemo.updateTrust(enableTrust, for: self.address) self.trustLevel = self.account.omemo.getTrustLevel(self.address, identityKey: self.fingerprint) } func getEntryInfoAlert() -> Alert { if(self.isOwnDevice) { return Alert( title: Text("Own device key"), message: Text("This key belongs to this device and cannot be removed or disabled!"), dismissButton: nil); } switch(self.trustLevel.int32Value) { case MLOmemoTrusted: return Alert( title: Text("Trusted and verified key"), message: Text("This key is trusted and verified by manually comparing fingerprints. To stop trusting this key, use the toggle element."), dismissButton: nil) case MLOmemoToFU: return Alert( title: Text("Trusted but unverified key"), message: Text("Monal currently trusts this key, but fingerprints were not compared yet. To increase security, please confirm with the contact that the displayed fingerprints do match before trusting this key!"), primaryButton: .destructive(Text("Trust Key"), action: { setTrustLevel(true) }), secondaryButton: .default(Text("Okay"))) case MLOmemoNotTrusted: return Alert( title: Text("Untrusted key"), message: Text("Monal does not trust this key. Either it was manually disabled or not manually verified while other keys of that contact are verified. You can trust this key by using the toggle element. Please ensure with the contact that fingerprints are matching before trusting this key."), dismissButton: nil) case MLOmemoTrustedButRemoved: return Alert( title: Text("Trusted but removed key"), message: Text("This key is trusted, but the contact does not use it anymore. Consider to disable trust for this key."), primaryButton: .default(Text("Dont' trust Key"), action: { setTrustLevel(false) }), secondaryButton: .cancel(Text("Okay"))) case MLOmemoTrustedButNoMsgSeenInTime: return Alert( title: Text("Trusted but unused key"), message: Text("This key is trusted, but the contact has not used it for a long time. Consider to disable trust for this key"), primaryButton: .default(Text("Don't trust Key"), action: { setTrustLevel(false) }), secondaryButton: .cancel(Text("Okay"))) default: return Alert( title: Text("Invalid State"), message: Text("The key is in a state that is currently not correctly handled. Please contact the developers if you see this prompt."), dismissButton: nil) } } // @ViewBuilder func getTrustLevelIcon() -> some View { var iconColor = Color.yellow var iconName = "key.fill" switch(self.trustLevel.int32Value) { case MLOmemoTrusted: iconColor = Color.green break case MLOmemoToFU: break case MLOmemoNotTrusted: iconColor = Color.red break case MLOmemoTrustedButRemoved: iconName = "trash.fill" case MLOmemoTrustedButNoMsgSeenInTime: iconName = "clock.fill" default: break } return Image(systemName: iconName) .frame(width: 30, height: 30, alignment: .center) .foregroundColor(Color.primary) .background(iconColor) .cornerRadius(30) } func getDeviceIconForOwnDevice() -> some View { var deviceImage: String = "iphone.homebutton.circle" if UIDevice.current.userInterfaceIdiom == .pad { #if targetEnvironment(macCatalyst) deviceImage = "laptopcomputer" #else deviceImage = "ipad" #endif } return Image(systemName: deviceImage) .resizable() .frame(width: 30, height: 30, alignment: .center) .foregroundColor(Color.primary) } var body: some View { let trustLevelBinding = Binding.init(get: { return (self.trustLevel.int32Value != MLOmemoNotTrusted) }, set: { keyEnabled in setTrustLevel(keyEnabled) }) let fingerprintString: String = self.fingerprint.isEmpty ? "" : HelperTools.signalHexKeyWithSpaces(with: fingerprint) let clipboardValue = "OMEMO fingerprint of \(self.contactJid), device \(self.deviceId): \(fingerprintString)" GroupBox { HStack(alignment:.bottom) { VStack(alignment:.leading) { HStack(alignment:.center) { Text("Device ID: ").font(.headline) Text(deviceId.stringValue) } Spacer() HStack(alignment:.center) { Text(fingerprintString) .font(Font.init( UIFont.monospacedSystemFont(ofSize: 11.0, weight: .regular) )) if(self.isBrokenSession) { Text("Encrypted session to this device broken beyond repair.").foregroundColor(.red) } } } .onTapGesture(count: 2) { UIPasteboard.general.setValue(clipboardValue, forPasteboardType:UTType.utf8PlainText.identifier) showClipboardCopy = true } Spacer() // the trust level of our own device should not be displayed if(!isOwnDevice) { VStack(alignment:.center) { Button { showEntryInfo = true } label: { getTrustLevelIcon() } Toggle("", isOn: trustLevelBinding).font(.footnote) .labelsHidden() //make sure we do not need more space than the actual toggle needs } } else { Button { showEntryInfo = true } label: { getDeviceIconForOwnDevice() } } } .alert(isPresented: $showEntryInfo) { getEntryInfoAlert() } .alert(isPresented: $showClipboardCopy) { Alert( title: Text("Copied to clipboard"), message: Text(clipboardValue), dismissButton: nil ); } } } } struct OmemoKeysForContactView: View { @State private var showDeleteKeyAlert = false @State private var selectedDeviceForDeletion : NSNumber @ObservedObject private var devices: OmemoKeysForContact private var deviceId: NSNumber { return account.omemo.getDeviceId() } private var deviceIds: OrderedSet { return OrderedSet(devices.devices.sorted { $0.intValue < $1.intValue }) } private let contactJid: String private let account: xmpp private let ownKeys: Bool init(contact: ObservableKVOWrapper, devices: OmemoKeysForContact) { let account = (contact.account as xmpp?)! self.ownKeys = (account.connectionProperties.identity.jid == contact.obj.contactJid) self.contactJid = contact.obj.contactJid self.account = account self.devices = devices self.selectedDeviceForDeletion = -1 } func deleteButton(deviceId: NSNumber) -> some View { Button(action: { selectedDeviceForDeletion = deviceId // SwiftUI does not like to have deviceID nested in multiple functions, so safe this in the struct... showDeleteKeyAlert = true }, label: { Image(systemName: "xmark.circle.fill").foregroundColor(.red) }) .buttonStyle(.borderless) .offset(x: -7, y: -7) .alert(isPresented: $showDeleteKeyAlert) { Alert( title: Text("Do you really want to delete this key?"), message: Text("DeviceID: " + self.selectedDeviceForDeletion.stringValue), primaryButton: .destructive(Text("Delete Key")) { if(deviceId == -1) { return // should be unreachable } account.omemo.deleteDevice(forSource: self.contactJid, andRid: self.selectedDeviceForDeletion) }, secondaryButton: .cancel(Text("Abort")) ) } } var body: some View { ForEach(self.deviceIds, id: \.self) { deviceId in HStack { ZStack(alignment: .topLeading) { OmemoKeysEntryView(account: self.account, contactJid: self.contactJid, deviceId: deviceId, isOwnDevice: (ownKeys && deviceId == self.deviceId)) if(ownKeys == true) { if(deviceId != self.deviceId) { deleteButton(deviceId: deviceId) } } } } } } } struct OmemoKeysForChatView: View { private var viewContact: ObservableKVOWrapper? // store initial contact with which the view was initialized for refreshs... private var account: xmpp? // Needed for the alert message that is displayed when the scanned contact is not in the group @State private var scannedJid : String = "" @State private var scannedFingerprints : Dictionary = [:] @State var selectedContact : ObservableKVOWrapper? // for reason why see start of body @State private var navigateToQRCodeView = false @State private var navigateToQRCodeScanner = false @State private var showScannedContactMissmatchAlert = false @ObservedObject private var omemoKeys: OmemoKeysForChat private var contacts: [(ObservableKVOWrapper, OmemoKeysForContact)] { return omemoKeys.contacts.sorted { (entry1, entry2) -> Bool in let entry1Jid: String = entry1.0.contactJid let entry2Jid: String = entry2.0.contactJid return entry1Jid < entry2Jid } } init(omemoKeys: OmemoKeysForChat) { self.account = omemoKeys.viewContact?.account self.selectedContact = nil self.viewContact = omemoKeys.viewContact self.omemoKeys = omemoKeys } private func isOwnKeys() -> Bool { if let contact = self.viewContact, let account = self.account { let isMuc = contact.isMuc && contact.mucType == kMucTypeGroup let isOwnJid = account.connectionProperties.identity.jid == contact.contactJid return !isMuc && isOwnJid } return false } func resetTrustFromQR(scannedJid : String, scannedFingerprints : Dictionary) { //don't untrust other devices not included in here, because conversations only exports its own fingerprint // // untrust all devices from jid // self.account!.omemo.untrustAllDevices(from: scannedJid) // trust all devices that were part of the qr code let knownDevices = Array(self.account!.omemo.knownDevices(forAddressName: scannedJid)) for (qrDeviceId, fingerprint) in scannedFingerprints { let address = SignalAddress(name: scannedJid, deviceId: Int32(qrDeviceId)) let identityFromHex = HelperTools.signalIdentity(withHexKey: fingerprint) // insert fingerprint of unkown devices to signalstore if(!knownDevices.contains(NSNumber(integerLiteral: qrDeviceId))) { self.account!.omemo.addIdentityManually(address, identityKey: identityFromHex) assert(self.account!.omemo.getIdentityFor(address) == identityFromHex, "The stored and created fingerprint should match") } // trust device/fingerprint if fingerprints match let identity = self.account!.omemo.getIdentityFor(address) let knownIdentity = HelperTools.signalHexKey(with: identity) if(knownIdentity.uppercased() == fingerprint.uppercased()) { self.account!.omemo.updateTrust(true, for: address) } } } var body: some View { // workaround for the fact that NavigationLink inside a form forces a formatting we don't want if(self.selectedContact != nil) { // selectedContact is set to a value either when the user presses a QR code button or if there is only a single contact to choose from (-> user views a single account) NavigationLink(destination:LazyClosureView(OmemoQrCodeView(contact: self.selectedContact!)), isActive: $navigateToQRCodeView){}.hidden().disabled(true) // navigation happens as soon as our button sets navigateToQRCodeView to true... // NavigationLink(destination: LazyClosureView(MLQRCodeScanner( // handleContact: { jid, fingerprints in // // we scanned a contact but it was not in the contact list, show the alert... // self.scannedJid = jid // self.scannedFingerprints = fingerprints // showScannedContactMissmatchAlert = true // }, handleClose: {} // )), isActive: $navigateToQRCodeScanner){}.hidden().disabled(true) } List { let helpDescription = isOwnKeys() ? Text("These are your encryption keys. Each device is a different place you have logged in. You should trust a key when you have verified it. Double tap onto a fingerprint to copy to clipboard.") : Text("You should trust a key when you have verified it. Verify by comparing the key below to the one on your contact's screen. Double tap onto a fingerprint to copy to clipboard.") Section(header:helpDescription) { if (omemoKeys.contacts.count == 1) { ForEach(self.contacts, id: \.0) { contact, devices in OmemoKeysForContactView(contact: contact, devices: devices) } } else { ForEach(self.contacts, id: \.0) { contact, devices in DisclosureGroup(content: { OmemoKeysForContactView(contact: contact, devices: devices) }, label: { HStack { Text("Keys of \(contact.obj.contactJid)") Spacer() Button(action: { self.selectedContact = contact self.navigateToQRCodeView = true }, label: { Image(systemName: "qrcode.viewfinder") }).buttonStyle(.borderless) } }) } } } } .listStyle(.plain) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { HStack{ /*if(self.account != nil) { Button(action: { self.navigateToQRCodeScanner = true }, label: { Image(systemName: "camera.fill") }) }*/ if(omemoKeys.contacts.count == 1 && self.account != nil) { Button(action: { self.navigateToQRCodeView = true }, label: { Image(systemName: "qrcode.viewfinder") }) } } } } .navigationBarTitle(isOwnKeys() ? Text("My Encryption Keys") : Text("Encryption Keys"), displayMode: .inline) .onAppear(perform: { self.selectedContact = self.omemoKeys.contacts.keys.first // needs to be done here as first is nil in init }) .alert(isPresented: $showScannedContactMissmatchAlert) { Alert( title: Text("QR code: Fingerprints found"), message: Text("Do you want to trust the scanned fingerprints of contact \(self.scannedJid) when using your account \(self.account!.connectionProperties.identity.jid)?"), primaryButton: .cancel(Text("No")), secondaryButton: .default(Text("Yes"), action: { resetTrustFromQR(scannedJid: self.scannedJid, scannedFingerprints: self.scannedFingerprints) self.scannedJid = "" self.scannedFingerprints = [:] })) } } } struct OmemoKeysView: View { @ObservedObject private var omemoKeys: OmemoKeysForChat init(omemoKeys: OmemoKeysForChat) { self.omemoKeys = omemoKeys } private var viewContact: ObservableKVOWrapper? { return omemoKeys.viewContact } private var account: xmpp? { return viewContact?.account } private var contacts: Set> { return Set(omemoKeys.contacts.keys) } var body: some View { if self.account != nil && !self.contacts.isEmpty { OmemoKeysForChatView(omemoKeys: omemoKeys) } else if self.account == nil { ContentUnavailableShimView("Account Disabled", systemImage: "iphone.homebutton.slash", description: Text("Cannot display keys as the account is disabled.")) } else if self.contacts.isEmpty { ContentUnavailableShimView("No Contacts", systemImage: "person.2.slash", description: Text("Cannot display keys as there are no contacts to display keys for.")) } } } class OmemoKeysForContact: ObservableObject { @Published var devices: Set init(devices: Set) { self.devices = devices } } class OmemoKeysForChat: ObservableObject { @Published var contacts: Dictionary, OmemoKeysForContact> var viewContact: ObservableKVOWrapper? private var subscriptions: Set = Set() init(viewContact: ObservableKVOWrapper?) { self.viewContact = viewContact self.contacts = OmemoKeysForChat.knownDevices(viewContact: self.viewContact) subscriptions = [ NotificationCenter.default.publisher(for: NSNotification.Name("kMonalOmemoStateUpdated")) .receive(on: DispatchQueue.main) .sink() { _ in self.updateContactDevices() }, NotificationCenter.default.publisher(for: NSNotification.Name("kMonalMucParticipantsAndMembersUpdated")) .receive(on: DispatchQueue.main) .sink() { _ in self.updateContactDevices() }, ] } private func updateContactDevices() -> Void { withAnimation() { self.contacts = OmemoKeysForChat.knownDevices(viewContact: self.viewContact) } } private static func knownDevices(viewContact: ObservableKVOWrapper?) -> Dictionary, OmemoKeysForContact> { let contacts: OrderedSet> = getContactList(viewContact: viewContact) let devices = contacts.map { ($0, devicesForContact(contact: $0)) } return Dictionary(uniqueKeysWithValues: devices) } private static func devicesForContact(contact: ObservableKVOWrapper) -> OmemoKeysForContact { let account: xmpp = (contact.account as xmpp?)! let devicesForContact: Set = account.omemo.knownDevices(forAddressName: contact.contactJid) return OmemoKeysForContact(devices: devicesForContact) } } struct OmemoKeys_Previews: PreviewProvider { static var previews: some View { // TODO some dummy views, requires a dummy xmpp obj OmemoKeysView(omemoKeys: OmemoKeysForChat(viewContact: nil)); } }