End-to-End Encrypted iOS Chat with Apple’s CryptoKit

Matheus C.
Matheus C.
Published November 4, 2020

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.

Image shows two chat screens, one with messages encrypted and the other with messages decrypted

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

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

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.

Image shows the stream chat swift SDK banner image

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.

Animation shows an encrypted chat screen alternating every second between encrypted and decrypted messages

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.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->