Skip to content

Instantly share code, notes, and snippets.

@thedore17
Last active July 10, 2019 06:10
Show Gist options
  • Save thedore17/b8a1f7d92b9df53aa3b50d415e90a771 to your computer and use it in GitHub Desktop.
Save thedore17/b8a1f7d92b9df53aa3b50d415e90a771 to your computer and use it in GitHub Desktop.
This is the source code for the Cloakroom iOS app's private chat feature. It's end-to-end cryptography is powered by Virgil Security's open source cryptographic library. All encryption and decryption is handled here on the front-end. The users' private encryption keys never leave the local device.
//
// ChatPostDetailViewController.swift
// Cloakroom
//
// Created by Theodore Henderson on 3/31/16.
// Copyright © 2016 Capitol Bells, Inc. All rights reserved.
//
import SwiftyJSON
private extension Selector {
static let newChat =
#selector(ChatPostDetailViewController.handleRefresh)
}
class ChatPostDetailViewController: PostDetailViewController {
// MARK: - Outlets
@IBOutlet weak var navItem: UINavigationItem!
@IBOutlet weak var optionsButton: UIBarButtonItem!
@IBOutlet weak var containerView: UIView!
@IBOutlet weak var encryptionButton: UIBarButtonItem!
// MARK : - Actions
@IBAction func optionsActionTUI(_ sender: AnyObject) {
presentChatOptions()
}
@IBAction func backButtonTUI(_ sender: AnyObject) {
//deleteCounterpartyChats()
navigationController?.popViewController(animated: true)
}
@IBAction func encryptionAction(_ sender: UIBarButtonItem) {
if CurrentUser.hasBeenAskedToEnableEncryption().get() == false {
CurrentUser.hasBeenAskedToEnableEncryption().set()
enableEncryptionAlert()
} else {
encryptionActionLogic(sender)
}
}
// MARK : - Variables
var intro: IntroModel?
var chatArray = [ChatModel]()
var blockReasonTextField: UITextField!
var firstLoad = true
var privateKey: Data?
var publicKey: Data?
var chatVisible = false
//MARK: - Override Functions
override func viewDidLoad() {
super.viewDidLoad()
//populateRandomChats()
navigationController?.navigationBar.barTintColor = Constants.Colors.appDarkBlue
showHUDOnView(containerView, text: "Loading")
getChatsForIntro(refresh: false)
watchForScreenCapture()
getEncryptionKeys()
postDetailTableView.addSubview(refreshControl)
autofillTableViewSetup()
setScrollViewsScrollToTap()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
chatVisible = true
DispatchQueue.main.async(execute: {self.checkIntroForEncryptionState(self.intro)})
}
override func viewDidDisappear(_ animated: Bool) {
chatVisible = false
}
override func commentTableViewSetup() {
if intro?.counterpartyAlias != nil {
navItem.title = "\(intro!.counterpartyAlias!)"
}
placeholderText = "As \(intro!.myAlias!)"
postDetailTableView.register(UINib(nibName: "ChatCommentCell", bundle: nil), forCellReuseIdentifier: Constants.CellIdentifer.chatCell)
postDetailTableView.rowHeight = UITableViewAutomaticDimension
postDetailTableView.estimatedRowHeight = 120.0
NotificationCenter.default.addObserver(self, selector: Selector.newChat, name:NSNotification.Name(rawValue: "NewChat"), object: nil)
}
override func setAliasPlaceHolderText() {
placeholderText = "As \(intro!.myAlias!)"
commentTextView.text = placeholderText
}
override func longCommentAlert() {
self.displayHelperAlert("Chat messages must be less than \(Constants.CharacterLimits.maxCommentLength) characters long.")
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if tableView.tag == 99 {
if chatArray.count > 0 {
return chatArray.count
} else if firstLoad == true {
return 0
} else {
return 1
}
} else {
return filteredAutofillAliasArray.count
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if tableView.tag == 99 {
if chatArray.count > 0 {
let chatCell = tableView.dequeueReusableCell(withIdentifier: Constants.CellIdentifer.chatCell, for: indexPath) as! ChatCommentTableViewCell
let chatIndex = indexPath.row
let chat = chatArray[chatIndex]
chatCell.setupChatCell(chat)
chatCell.formatCommentCellUI()
chatCell.commentTableViewCellDelegate = self
return chatCell
} else {
// Return empty cell here
let emptyCell = tableView.dequeueReusableCell(withIdentifier: "emptyChatCell", for: indexPath)
return emptyCell
}
} else {
let autofillCell = tableView.dequeueReusableCell(withIdentifier: "autofillAliasCell")! as UITableViewCell
autofillCell.textLabel?.text = filteredAutofillAliasArray[indexPath.row]
return autofillCell
}
}
override func sendCommentLogic(_ sender: UIButton) {
askForCommentResponsePushIfNecessaryAndComment(UIApplication.shared, sender: sender)
commentTextView.resignFirstResponder()
}
override func attemptSendComment(_ sender: UIButton) {
super.activeTextViewHeight.constant = defaultTextViewHeight
let commentText = commentTextView.text
if commentText!.characters.count > 0 && commentText!.characters.count <= Constants.CharacterLimits.maxCommentLength && !isStringEmpty(commentText!) && commentText != placeholderText {
if intro?.isEncrypted == false {
sendChatAPIHandler(sender, chatText: commentText!)
} else {
sendEncryptedChatAPIHandler(sender, chatText: commentText!)
}
} else if commentTextView.text.characters.count > Constants.CharacterLimits.maxCommentLength {
longCommentAlert()
print("Long comment alert")
} else {
print("What happened here??")
}
}
override func didDeleteCommentWithKey(_ commentKeyToDelete: String) {
// CommentKey here is actually a ChatKey
showHUDForDuration("Message Deleted")
var i = 0
for chat in chatArray {
if chat.chatKey == commentKeyToDelete {
chatArray.remove(at: i)
postDetailTableView.reloadData()
break
}
i += 1
}
}
//MARK: - Controls
override func addPostAuthorAndCommentersToMentionsArray() {
// THis is chat, so only add coutnerparty alias here
if intro?.counterpartyAlias != nil {
if !originalAutofillAliasArray.contains((intro?.counterpartyAlias)!) {
originalAutofillAliasArray.append((intro?.counterpartyAlias)!)
}
}
}
override func getCommentsForPostShowLoadingIndicator() {
//nothing
}
override func handleRefresh() {
// Get chats for intro API call
if intro != nil {
getChatsForIntro(refresh: true)
}
}
// func populateRandomChats() {
// var i = 0
// while i < 10 {
// let newChat = ChatModel.init(NSNumber(i))
//
// chatArray.append(newChat)
// i += 1
// }
// }
func presentChatOptions() {
let actionSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
// ACTION 1
let viewProfileAction = UIAlertAction(title: "View Profile", style: .default) { (action) in
self.segueToCounterpartyProfile()
}
actionSheet.addAction(viewProfileAction)
// ACTION 2
if checkIntroForEncryptionState(intro) == true {
var encryptionTitle = "Turn Encryption On"
if intro?.isEncrypted == true {encryptionTitle = "Turn Encryption Off"}
let encryptionAction = UIAlertAction(title: encryptionTitle, style: .default) { (action) in
self.enableEncryptionAlert()
}
actionSheet.addAction(encryptionAction)
}
// ACTION 3
let clearAction = UIAlertAction(title: "Wipe Conversation", style: .default) { (action) in
self.deleteAllChats()
}
actionSheet.addAction(clearAction)
// ACTION 4
let blockAction = UIAlertAction(title: "Block User", style: .destructive) { (action) in
self.blockUserAlert()
}
actionSheet.addAction(blockAction)
// ACTION 5
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { (action) in
// do nothing
}
actionSheet.addAction(cancelAction)
if actionSheet.popoverPresentationController != nil {
actionSheet.popoverPresentationController!.sourceView = self.view
actionSheet.popoverPresentationController!.sourceRect = CGRect(x: self.view.bounds.size.width / 2.0, y: view.bounds.size.height / 2.0, width: 1.0, height: 1.0)
}
DispatchQueue.main.async(execute: {self.present(actionSheet, animated: true, completion: nil)})
}
func blockUserAlert() {
present(
AlertViewManager.blockUserAlert({ (textField) in
//handle the contents of the textField
textField.placeholder = "Reason for blocking"
self.blockReasonTextField = textField
}, targetAction: blockAlertAction()), animated: true, completion: nil)
}
func blockAlertAction() -> UIAlertAction {
let blockAction = UIAlertAction(title: "Block", style: .destructive, handler: {(action) in
if self.blockReasonTextField.text == nil {
self.blockUser(reason: "")
} else {
self.blockUser(reason: self.blockReasonTextField.text!)
}
})
return blockAction
}
// MARK: - Encryption Logic
func enableEncryptionAlert() {
present(
AlertViewManager.defaultActionAlert(title: "Enable Encryption?", message: "Secure this conversation by enabling end-to-end encryption. You will only be able to read these encrypted messages from this device.", style: .alert, action: EnableEncryptionAlertAction()), animated: true, completion: nil)
}
func EnableEncryptionAlertAction() -> UIAlertAction {
let encryptAction = UIAlertAction(title: "Encrypt", style: .destructive, handler: {(action) in
self.toggleEncryptionEndpoint(true)
})
return encryptAction
}
func encryptionActionLogic(_ sender: UIBarButtonItem) {
if intro!.isEncrypted == true {
toggleEncryptionEndpoint(false)
} else {
enableEncryptionAlert()
}
}
func toggleEncryptionUI(_ encrypted: Bool) {
if encrypted == true {
encryptionButton.title = "🔒"
navigationController?.navigationBar.barTintColor = Constants.Colors.appDarkGrey
} else {
encryptionButton.title = "🔓"
navigationController?.navigationBar.barTintColor = Constants.Colors.appDarkBlue
}
}
func checkPublicKeyConsistency() {
if intro?.myPublicKey != publicKey {
updateEncryptionKeyForIntro(intro!.introKey!)
}
}
func getEncryptionKeys() {
publicKey = CurrentUser.encryptionKeys().getPublicKey() as Data?
privateKey = CurrentUser.encryptionKeys().getPrivateKey() as Data?
if publicKey == nil || privateKey == nil {
let newKeyPair = CurrentUser.encryptionKeys().generateNewKeyPair()
privateKey = newKeyPair.privateKey()
publicKey = newKeyPair.publicKey()
}
}
func encryptMessage(_ message: String) -> Data? {
let message = NSString(string: message)
// Convert message String to NSData
if let toEncrypt = message.data(using: String.Encoding.utf8.rawValue, allowLossyConversion: false) {
let cryptor = VSSCryptor()
do {
try cryptor.addKeyRecipient(intro!.counterpartyAlias!, publicKey: intro!.counterpartyPublicKey! as Data, error: ())
// Encrypting the message data using counterparty's public key
var encryptedData = Data()
encryptedData = try cryptor.encryptData(toEncrypt, embedContentInfo: true, error: ())
return encryptedData
}
catch let error as NSError {
print("Error: \(error.localizedDescription)")
}
//...
}
return nil
}
func decryptMessage(_ encryptedData: Data) -> String? {
let decryptor = VSSCryptor()
do {
// Decrypting the data with current user's private key
let decryptedData = try decryptor.decryptData(encryptedData, recipientId: intro!.myAlias!, privateKey: privateKey!, keyPassword: nil, error: ())
// Convert the decrypted data to a String
if let decryptedMessage = NSString(data: decryptedData, encoding: String.Encoding.utf8.rawValue) {
return decryptedMessage as String
} else {
return nil
}
}
catch let error as NSError {
print("Error: \(error.localizedDescription)")
return nil
}
}
func checkIntroForEncryptionState(_ currentIntro: IntroModel?) -> Bool {
// check if is encrypted
// if yes, toggle encryption UI
if currentIntro!.myPublicKey == nil && currentIntro != nil {
updateEncryptionKeyForIntro(currentIntro!.introKey!)
disableAndHideEncryptionButton()
return false
} else if currentIntro!.counterpartyPublicKey?.count == 0 {
disableAndHideEncryptionButton()
return false
} else {
encryptionButton.isEnabled = true
if currentIntro != nil {toggleEncryptionUI(currentIntro!.isEncrypted!)}
return true
}
}
func disableAndHideEncryptionButton() {
encryptionButton.isEnabled = false
encryptionButton.title = ""
intro!.isEncrypted = false
}
//MARK: - Screen Capture Logic
func watchForScreenCapture() {
NotificationCenter.default.addObserver(
forName: NSNotification.Name.UIApplicationUserDidTakeScreenshot,
object: nil,
queue: OperationQueue.main)
{
notification in
self.screenCaptureDetected()
}
}
func screenCaptureDetected() {
if self == navigationController?.visibleViewController && chatVisible == true {
if let counterpartyAlias = intro?.counterpartyAlias {
if chatArray.count > 0 {
reportScreenCaptureToBackend(counterpartyAlias)
}
}
} else {
// This isn't the visible view controller, so ignore this.
}
}
func reportScreenCaptureToBackend(_ counterpartyAlias: String) {
RequestManager.alertByScreenshot(CurrentUser.key().get()!, introKey: (intro?.introKey)!, offenderAlias: (intro?.myAlias)!, counterpartyAlias: counterpartyAlias, success: {(data) in
self.reportedScreenCaptureSuccess(counterpartyAlias)
}, failure: {(error) in
print("Failure reporting screenshot to backend.")
self.failureGetPostDetails(error)
})
}
func reportedScreenCaptureSuccess(_ counterpartyAlias: String) {
displayHelperAlertDefault("Shame", message: "Shame. Shame. We've notified \(counterpartyAlias) that you took a screenshot.")
}
//MARK: - API Calls
func getChatsForIntro(refresh shouldRefresh: Bool) {
RequestManager.getChatMessages(CurrentUser.key().get()!, introKey: (intro?.introKey)!, recipient: (intro?.counterpartyAlias)!, sender: (intro?.myAlias)!, success: {(data) in
//print(data)
self.refreshControl.endRefreshing()
self.hideHUDOnView(self.containerView)
self.firstLoad = false
self.successGetChats(data, refresh: shouldRefresh)
}, failure: {(error) in
self.refreshControl.endRefreshing()
self.firstLoad = false
self.hideHUDOnView(self.postDetailTableView)
})
}
func sendChatAPIHandler(_ sender: UIButton, chatText: String) {
self.showHUDOnView(postDetailTableView, text: "")
sender.isEnabled = false
RequestManager.sendChatMessage(CurrentUser.key().get()!, forIntro: intro!.introKey!, from: intro!.myAlias!, to: (intro?.counterpartyAlias!)!, message: chatText, success: {(data) -> Void in
self.hideHUDOnView(self.postDetailTableView)
sender.isEnabled = true
self.successSentChat(data, chatText: chatText)
}, failure: {error in
self.hideHUDOnView(self.postDetailTableView)
self.failureGetPostDetails(error)
})
}
func sendEncryptedChatAPIHandler(_ sender: UIButton, chatText: String) {
self.showHUDOnView(postDetailTableView, text: "🔒")
sender.isEnabled = false
let encryptedData = encryptMessage(chatText)
RequestManager.sendEncryptedChatMessage(userKey: CurrentUser.key().get()!, introKey: (intro?.introKey!)!, sender: (intro?.myAlias!)!, recipient: (intro?.counterpartyAlias!)!, encryptedData: encryptedData!, success: { (data) -> Void in
self.hideHUDOnView(self.postDetailTableView)
sender.isEnabled = true
//print(data)
self.successSentChat(data, chatText: chatText)
}) { (error) -> Void in
self.hideHUDOnView(self.postDetailTableView)
self.failureGetPostDetails(error)
}
}
func updateEncryptionKeyForIntro(_ introKey: String) {
var publicKey = CurrentUser.encryptionKeys().getPublicKey()
if publicKey == nil {
let keyPair = CurrentUser.encryptionKeys().generateNewKeyPair()
publicKey = keyPair.publicKey()
}
RequestManager.updateEncryptionKey(userKey: CurrentUser.key().get()!, alias: (intro?.myAlias!)!, introKey: introKey, publicKey: publicKey!, success: { (data) in
// Replace intro with returned intro object
//print(data)
self.successUpdateEncryptionKeyForIntro(data)
}) { (error) in
// Background call, do nothing on failure
print(error)
}
}
func toggleEncryptionEndpoint(_ enableEncryption: Bool) {
RequestManager.toggleEncryption(CurrentUser.key().get()!, introKey: intro!.introKey!, enable: enableEncryption, success: { (data) in
self.successToggleEncryptionEndpoint(data)
}) { (error) in
self.hideHUDOnView(self.postDetailTableView)
self.failureGetPostDetails(error)
}
}
// MARK: API Call Response Handlers
func successGetChats(_ data: (AnyObject!), refresh: Bool) {
var json = JSON(data!)
let result = json["result"].string!
if (result == "ok") {
//print(privateKey)
for (_, object) in json["chats"] {
let chat = ChatModel(object: object)
if isDuplicateChat(chat) == false {
chatArray.append(chat)
}
if chat.encrypted_message != "" {
let encryptedData = chat.encrypted_message!.data(using: String.Encoding.isoLatin1)
chat.message = decryptMessage(encryptedData!)
if chat.message == nil {
chat.message = "***ENCRYPTED MESSAGE***"
}
chat.encrypted_message = ""
chat.isEncrypted = true
} else {
chat.isEncrypted = false
}
}
chatArray = chatArray.sorted(by: { $0.timestamp!.intValue < $1.timestamp!.intValue})
postDetailTableView.reloadData()
let numberOfRows = self.postDetailTableView.numberOfRows(inSection: 0)
if numberOfRows > 0 {
self.scrollTableToBottom(IndexPath(row: numberOfRows-1, section: 0))
}
checkPublicKeyConsistency()
encryptionButton.isEnabled = true
} else {
let message = json["message"].string!
self.displayErrorAlert(message)
}
}
func successToggleEncryptionEndpoint(_ data: (AnyObject!)) {
var json = JSON(data!)
let result = json["result"].string!
if (result == "ok") {
//print(json["intro"])
self.intro = IntroModel(object: json["intro"])
self.checkIntroForEncryptionState(self.intro)
if intro?.isEncrypted == true {
showHUDForDuration("Encryption ON")
} else {
showHUDForDuration("Encryption OFF")
}
} else {
//let message = json["message"].string!
//print(json)
//print(message)
}
}
func successUpdateEncryptionKeyForIntro(_ data: (AnyObject!)) {
var json = JSON(data!)
let result = json["result"].string!
if (result == "ok") {
self.intro = IntroModel(object: json["intro"])
self.toggleEncryptionUI((self.intro?.isEncrypted)!)
} else {
let message = json["message"].string!
self.displayErrorAlert(message)
}
}
func successSentChat(_ data: (AnyObject!), chatText: String) {
var json = JSON(data!)
let result = json["result"].string!
if (result == "ok") {
let myNewChat = ChatModel(object: json["chat"])
if myNewChat.encrypted_message != "" {
myNewChat.encrypted_message = ""
myNewChat.message = chatText
myNewChat.isEncrypted = true
} else {
myNewChat.isEncrypted = false
}
chatArray.append(myNewChat)
chatArray = chatArray.sorted(by: { $0.timestamp!.intValue < $1.timestamp!.intValue})
postDetailTableView.reloadData()
commentTextView.text = ""
activeTextViewHeight.constant = defaultTextViewHeight
characterCountLabel.isHidden = true
let numberOfRows = self.postDetailTableView.numberOfRows(inSection: 0)
if numberOfRows > 0 {
scrollTableToBottom(IndexPath(row: numberOfRows-1, section: 0))
}
} else {
let message = json["message"].string!
self.displayErrorAlert(message)
}
}
func getChatKeysToDelete() -> [String] {
var chatKeysToDeleteArray = [String]()
for chat in chatArray {
chatKeysToDeleteArray.append(chat.chatKey!)
}
return chatKeysToDeleteArray
}
func deleteIntro() {
RequestManager.deleteIntro(CurrentUser.key().get()!, intro: (intro?.introKey!)!, success: { (data) -> Void in
self.hideHUD()
self.successDeleteIntroJSONResponseHandler(data)
}) { (error) -> Void in
self.hideHUD()
self.displayErrorAlert((error?.localizedDescription)!)
}
}
func blockUser(reason reasonToBlock: String) {
RequestManager.blockChat(CurrentUser.key().get()!, aliasToBlock: (intro?.counterpartyAlias!)!, reasonForBlocking: reasonToBlock, success: { (data) -> Void in
self.hideHUD()
self.successDeleteIntroJSONResponseHandler(data)
}) { (error) -> Void in
self.hideHUD()
self.displayErrorAlert((error?.localizedDescription)!)
}
}
func deleteAllChats() {
let chatKeysToDelete: [String] = getChatKeysToDelete()
if chatKeysToDelete.count > 0 {
RequestManager.deleteChatMessages(CurrentUser.key().get()!, chatKeys: chatKeysToDelete, success: { (data) -> Void in
self.successDeleteCounterpartyChatsJSONResponseHandler(data)
}) { (error) -> Void in
if error != nil {
self.displayErrorAlert(error!.localizedDescription)
} else {
print("Error: \(error)")
}
}
} else {
// No chats to delete
}
}
override func getUserFollows() {
RequestManager.getUserFollows(userKey: CurrentUser.key().get()!, success: { (data) in
let json = JSON(data)
let result = json["result"]
if result == "ok" {
for alias in json["follows"].arrayObject as! [String] {
if !self.originalAutofillAliasArray.contains(alias) {
if CurrentUser().isAliasInAliasArray(alias) == false {
self.originalAutofillAliasArray.insert(alias, at: 0)
}
}
}
self.originalAutofillAliasArray = self.originalAutofillAliasArray.reversed()
}
}) { (error) in
self.displayErrorAlert(error!.localizedDescription)
}
}
func successDeleteIntroJSONResponseHandler(_ data: (AnyObject!)) {
let json = JSON(data!)
let result = json["result"].string!
if (result == "ok") {
showHUDForDuration("Cleared")
navigationController?.popViewController(animated: true)
} else {
hideHUD()
displayErrorAlert(json["message"].string!)
//showHUDForDuration("Error. Failed to delete.")
}
}
func successDeleteCounterpartyChatsJSONResponseHandler(_ data: (AnyObject!)) {
let json = JSON(data!)
//print(json)
let result = json["result"].string!
if (result == "ok") {
//print("Post successfully deleted.")
chatArray.removeAll()
postDetailTableView.reloadData()
} else {
self.displayErrorAlert(json["message"].string!)
//showHUDForDuration("Error. Failed to delete.")
}
}
func isDuplicateChat(_ newChat: ChatModel) -> Bool {
for existingChat in chatArray {
if existingChat.chatKey == newChat.chatKey {
return true
}
}
return false
}
//MARK: - Navigation Methods
func segueToCounterpartyProfile() {
let counterpartyProfileVC: CounterpartyProfileViewController = self.storyboard?.instantiateViewController(withIdentifier: "counterpartyProfileVC") as! CounterpartyProfileViewController
counterpartyProfileVC.profileAlias = intro?.counterpartyAlias
navigationController?.pushViewController(counterpartyProfileVC, animated: true)
}
}
struct CurrentUser {
struct encryptionKeys {
let publicKeyIdentifier = "encodedPublicKey-\(key().get())"
let privateKeyIdentifier = "encodedPrivateKey-\(key().get())"
func getPublicKey() -> Data? {
return defaults.data(forKey: publicKeyIdentifier)
}
func getPrivateKey() -> Data? {
return defaults.data(forKey: privateKeyIdentifier)
}
func set(_ encryptedKeyPair: VSSKeyPair) {
defaults.set(encryptedKeyPair.publicKey(), forKey: publicKeyIdentifier)
defaults.set(encryptedKeyPair.privateKey(), forKey: privateKeyIdentifier)
}
func remove() {
defaults.set(nil, forKey: publicKeyIdentifier)
defaults.set(nil, forKey: privateKeyIdentifier)
}
func generateNewKeyPair() -> VSSKeyPair {
let newKeyPair = VSSKeyPair(password:nil)
set(newKeyPair)
return newKeyPair
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment