In most cases, when building a chat app, it's essential to provide adequate privacy and security to your users. This can be done using cryptographic methods such as end-to-end encryption. End-to-end encryption is becoming a mainstream expectation, as it's featured in the biggest chat apps, such as WhatsApp and Telegram.
In this article, you'll learn how basic end-to-end encryption works in an iOS chat app using Apple's own CryptoKit framework for its secure and high-level encryption methods and Stream's flexible iOS chat SDK for its ready-made chat networking and UI components.
Please note that this tutorial is very basic and strictly educational, may contain simplifications, and rolling your own encryption protocol is not advisable. The algorithms used can contain certain 'gotchas' if not employed properly with the help of security professionals. Still, reading this article is a great start to get practical knowledge.
You can find the completed project on GitHub: encrypted-ios-chat. And if you have any questions, feel free to reach out to me on Twitter: @cardosodev :).
What Is End-to-End Encryption?
End-to-end encryption is a communication system where the only people who can read the messages are the people communicating. No eavesdropper can access the cryptographic keys needed to decrypt the conversation—not even a company that runs the messaging service.
What Is Apple's CryptoKit?
CryptoKit is a new Swift framework that makes it easier and safer than ever to perform cryptographic operations, whether you simply need to compute a hash or are implementing a more advanced authentication protocol.
What You Need
- Xcode 11 or later
- iOS 13 or later
- A Stream account
Basic Encryption Methods
Before we get to the chat part, let's start by defining the basic encryption methods we need. We'll integrate these methods in the next section.
1. Generating a Private Key
A private key is essential to end-to-end encryption. It is used alongside a public key to generate a symmetric key which is used for encryption and decryption of data. Each user in your application has a private and public key. They share their public keys with each other so that they can generate the same symmetric key. Alice shares her public key with Bob, and Bob uses his private key and Alice's public key to generate a symmetric key. Conversely, Bob shares his public key with Alice, and Alice uses her private key with Bob's public key to generate the same symmetric key. As long as Alice's and Bob's private keys are a secret to each of them, no one can generate the same symmetric key to decrypt their conversations.
To generate a private key, we'll use the P256.KeyAgreement.PrivateKey()
initializer.
import CryptoKit func generatePrivateKey() -> P256.KeyAgreement.PrivateKey { let privateKey = P256.KeyAgreement.PrivateKey() return privateKey }
Additionally, I chose the P256 algorithm for its cross-platform availability, as it is supported by the Web Crypto API in case you need a web version. Also, it's a good balance between performance and security. This preference can change with time as new algorithms become available.
PS: The public key can be extracted from the private key using privateKey.publicKey
. We'll touch on this in the integration part of this article.
The private key must be saved somewhere safe. In order to do this, you may need to turn it into a string format to save it and back to perform the cryptographic operations. You can do this with the following exportPrivateKey
method:
import Foundation import CryptoKit func exportPrivateKey(_ privateKey: P256.KeyAgreement.PrivateKey) -> String { let rawPrivateKey = privateKey.rawRepresentation let privateKeyBase64 = rawPrivateKey.base64EncodedString() let percentEncodedPrivateKey = privateKeyBase64.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! return percentEncodedPrivateKey }
And the importPrivateKey
method:
import Foundation import CryptoKit func importPrivateKey(_ privateKey: String) throws -> P256.KeyAgreement.PrivateKey { let privateKeyBase64 = privateKey.removingPercentEncoding! let rawPrivateKey = Data(base64Encoded: privateKeyBase64)! return try P256.KeyAgreement.PrivateKey(rawRepresentation: rawPrivateKey) }
2. Deriving Symmetric Key
Once a user (message sender) has the public key of the other user it wants to communicate with (message recipient), that user can generate the symmetric key using the methods: privateKey.sharedSecretFromKeyAgreement(with: publicKey)
and sharedSecret.hkdfDerivedSymmetricKey
.
import Foundation import CryptoKit func deriveSymmetricKey(privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) throws -> SymmetricKey { let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey) let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey( using: SHA256.self, salt: "My Key Agreement Salt".data(using: .utf8)!, sharedInfo: Data(), outputByteCount: 32 ) return symmetricKey }
This process is called a Diffie-Hellman Key Exchange and ensures the symmetric key can be shared between two users without ever leaving their devices.
3. Encrypt Text
Now, we use that symmetric key for encrypting texts using the AES.GCM.seal
method. We also encode it in a Base64 string, so it can be sent as normal text using the Stream Chat SDK in the integration step.
import Foundation import CryptoKit func encrypt(text: String, symmetricKey: SymmetricKey) throws -> String { let textData = text.data(using: .utf8)! let encrypted = try AES.GCM.seal(textData, using: symmetricKey) return encrypted.combined!.base64EncodedString() }
After the text is encrypted and encoded, it can be safely sent through the network.
4. Decrypt Text
After the recipient receives the encrypted text, they will decode it from Base64 and decrypt it using the AES.GCM.SealedBox
and AES.GCM.open
methods.
import Foundation import CryptoKit func decrypt(text: String, symmetricKey: SymmetricKey) -> String { do { guard let data = Data(base64Encoded: text) else { return "Could not decode text: \(text)" } let sealedBox = try AES.GCM.SealedBox(combined: data) let decryptedData = try AES.GCM.open(sealedBox, using: symmetricKey) guard let text = String(data: decryptedData, encoding: .utf8) else { return "Could not decode data: \(decryptedData)" } return text } catch let error { return "Error decrypting message: \(error.localizedDescription)" } }
After the text is decrypted, it should be readable and can be displayed in the UI.
Stream Chat Integration
In this section, you'll learn how to use the methods implemented above to achieve end-to-end encryption with Stream Chat's Swift SDK.
You can follow the "iOS Chat SDK Setup" step in the official tutorial to get a basic chat app working in minutes.
1. Public Key Extra Data
We need to define a custom publicKey
field for the User object so that users can share their public key with others. To do this, create the PublicKeyExtraData
type.
import StreamChatClient import Foundation struct PublicKeyExtraData: UserExtraDataCodable, Codable { var name: String? var avatarURL: URL? var publicKey: String? }
After that, you should set the User.extraDataType
to PublicKeyExtraData.self
before you initialize the Stream Chat Client in AppDelegate.swift
:
import UIKit import StreamChatClient [...] func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. User.extraDataType = PublicKeyExtraData.self Client.configureShared(.init(apiKey: "[api_key]", logOptions: .info)) return true } [...]
2. Custom Set User Method
Now, let's define our custom setUser
method so that the user can be registered in Stream Chat alongside its public key.
import Foundation import CryptoKit import StreamChatClient func setUser(userId: String, publicKey: P256.KeyAgreement.PublicKey, completion: @escaping (Result<UserConnection, ClientError>) -> Void) { let exportedPublicKey = exportPublicKey(publicKey) let extraData = PublicKeyExtraData( name: userId, avatarURL: URL(string: "https://getstream.io/random_png/?id=\(userId)&name=\(userId)"), publicKey: exportedPublicKey ) let sender = User(id: userId, extraData: extraData) Client.shared.set(user: sender, token: .development, completion) }
Additionally, the public key must be exported in a string format. You should define an exportPublicKey
method to do this.
import CryptoKit func exportPublicKey(_ publicKey: P256.KeyAgreement.PublicKey) -> String { let rawPublicKey = publicKey.rawRepresentation let base64PublicKey = rawPublicKey.base64EncodedString() let encodedPublicKey = base64PublicKey.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! return encodedPublicKey }
We're percent-encoding the public key after encoding to base64 to ensure the + sign is not ignored in case the public key is used in the URLs of specific requests.
In a future step, after we fetch another user's public key, we'll need to use the importPublicKey
method, which does the reverse of the exportPublicKey
.
import Foundation import CryptoKit func importPublicKey(_ publicKey: String) throws -> P256.KeyAgreement.PublicKey { let base64PublicKey = publicKey.removingPercentEncoding! let rawPublicKey = Data(base64Encoded: base64PublicKey)! let publicKey = try P256.KeyAgreement.PublicKey(rawRepresentation: rawPublicKey) return publicKey }
3. Encrypted Chat View Controller
Now that we generated the private key and registered our user with a public key, we need a custom ChatViewController
that encrypts messages before sending and decrypts messages before displaying.
To do this, let's create the EncryptedChatViewController
, which inherits from ChatViewController
.
import StreamChat import StreamChatClient import CryptoKit import UIKit class EncryptedChatViewController: ChatViewController { var symmetricKey: SymmetricKey! override func viewDidLoad() { super.viewDidLoad() presenter?.messagePreparationCallback = { var message = $0 do { message.text = try encrypt(text: message.text, symmetricKey: self.symmetricKey!) } catch let error { message.text = "Could not encrypt message: \(error.localizedDescription)" } return message } } override func messageCell(at indexPath: IndexPath, message: Message, readUsers: [User]) -> UITableViewCell { var message = message message.text = decrypt(text: message.text, symmetricKey: symmetricKey) return super.messageCell(at: indexPath, message: message, readUsers: readUsers) } }
As you can see, it contains an additional symmetricKey
property, which will be used for the encryption and decryption. In the viewDidLoad
we set the messagePreparationCallback
, which encrypts the messages before they're sent. We also override the messageCell
method to decrypt the message before displaying it.
3. Present Encrypted Chat
After implementing our custom ChatViewController
, now we can present it.
import Foundation import CryptoKit import StreamChatClient import StreamChatCore import UIKit extension UIViewController { func presentEncryptedChat(with userId: String, privateKey: P256.KeyAgreement.PrivateKey) { Client.shared.queryUsers(filter: .equal("id", to: userId)) { result in DispatchQueue.main.async { switch result { case let .success(users): guard let user = users.first else { return self.alert(title: "Error", message: "The recipient was not found") } guard let publicKey = (user.extraData as? PublicKeyExtraData)?.publicKey else { return self.alert(title: "Error", message: "The recipient doesn't have a public key") } do { let importedPublicKey = try importPublicKey(publicKey) let derivedKey = try deriveSymmetricKey(privateKey: privateKey, publicKey: importedPublicKey) let chatViewController = EncryptedChatViewController() chatViewController.symmetricKey = derivedKey chatViewController.presenter = ChannelPresenter(channel: Client.shared.channel(members: [Client.shared.user, user])) self.navigationController?.pushViewController(chatViewController, animated: true) } catch let error { self.alert(title: "Error", message: "Could not derive key: \(error)") } case let .failure(error): self.alert(title: "Error", message: error.localizedDescription) } } } } }
To present the EncryptedChatViewController
, we query the user we want to communicate with to get its public key and generate a symmetric key. After that, we set the symmetricKey
and presenter
properties and push the view controller into the navigation stack.
Next Steps With CryptoKit
Congratulations! You just learned how to implement basic end-to-end encryption in your iOS chat apps. It's important to know this is the most basic form of end-to-end encryption. It lacks some additional tweaks that can make it more bullet-proof for the real world, such as randomized padding, digital signature, and forward secrecy, among others. Also, for real-world usage, it's vital to get the help of application security professionals.