In this tutorial, we'll build encrypted chat on iOS using Swift. We'll combine Stream Chat and Virgil Security. Both Stream Chat and Virgil make it easy to create a solution with high security with all the features you expect. These two services allow developers to integrate chat that is zero-knowledge. The application embeds Virgil Security's E3Kit with Stream Chat's Swift components.
Note: All source code for this application is available on GitHub.
End-to-end encryption means that two people can have trade messages via the internet without anyone else being able to read them, even if the transmission or storage is compromised. To do this, the app encrypts the message before it leaves a user's device, and only the intended recipient can decrypt the message.
Virgil Security is a vendor that allows us to create end-to-end encryption via public/private key technology. Virgil provides a platform that allows us to create, store, and offer robust end-to-end encryption securely.
During this tutorial, we will create a Stream Chat app that uses Virgil's encryption to prevent anyone except the intended parties from reading messages. No one in your company, nor any cloud provider you use, can read these messages. Even if a malicious person gained access to the database containing them, all they would see is encrypted text, called ciphertext.
To build this application, we'll mostly rely on a few libraries from Stream Chat and Virgil (please check out the dependencies in the source to see what versions). Our final product will encrypt text on the device before sending a message. Decryption and verification will both happen in the receiver's device. Stream's Chat API will only see ciphertext, ensuring our user's data is never seen by anyone else, including you.
To accomplish this, the app performs the following process:
- A user authenticates with your backend.
- The iOS app requests a Stream auth token and API key from the
backend. The Swift app creates a Stream Chat Client for that user.
- The mobile app requests a Virgil auth token from the
backendand registers with Virgil, which generates their private and public key. The app stores the private key locally, and the public key in Virgil Cloud.
- Once the user decides who they want to chat with, the app creates and joins a Stream Chat Channel.
- The app asks Virgil's API, via E3Kit, for the receiver's public key.
- The user types a message and encrypts it with the receiver's public key using E3Kit, then sends it to Stream. After that, Stream Chat relays the message to the receiver. Stream only receives ciphertext, meaning they will never see the original message.
- When the message is received, the app decrypts and verifies it is using E3Kit and passes it along to Stream Chat's iOS components for display.
While this looks complicated, Stream and Virgil do most of the work for us. We'll use Stream's out-of-the-box UI components to render the chat UI and Virgil to do all of the cryptography and key management. We simply combine these services.
The code is split between the iOS frontend contained in the
ios folder, and the Express (Node.js) backend is found in the
backend folder. See the
README.md in each folder to see installing and running instructions. If you'd like to follow along with running code, make sure you get both the
ios running before continuing.
Let's walk through and look at the critical code needed for each step.
Basic knowledge of iOS (Swift) and Node.js is expected. This code is intended to run locally on your machine.
You will need an account with Stream and Virgil. Once you've created your accounts, you can place your credentials in
backend/.env if you'd like to run the code. You can use
backend/.env.example as a reference for the required credentials. Please see the README in the
backend directory for more information.
For our Swift app to securely interact with Stream and Virgil, the
backend provides four endpoints:
POST /v1/authenticate: This endpoint generates an auth token that allows the iOS application to communicate with the other endpoints. To keep things simple, this endpoint enables the client to be any user. The frontend tells the backend who it wants to authenticate as. In your application, this should be replaced with real authentication appropriate for your app.
POST /v1/stream-credentials: This returns the data required for the iOS app to establish a session with Stream. In order return this info we need to tell Stream this user exists and ask them to create a valid auth token:
The response payload has this shape:
apiKeyis the Stream account identifier for your Stream instance. It is needed to identify what account your frontend is trying to connect with.
tokenJWT token to authorize the frontend with Stream.
user: This object contains the data that the frontend needs to connect and render the user's view.
POST /v1/virgil-credentials: This returns the authentication token used to connect the frontend to Virgil. We use the Virgil Crypto SDK to generate a valid auth token for us:
In this case, the frontend only needs the auth token.
GET /v1/users: Endpoint for returning all users, which exists just to get a list of people to chat with. Please refer to the source if you're curious. Please note the
backenduses in-memory storage, so if you restart, it will forget all of the users.
The first step is to authenticate a user and get our Stream and Virgil credentials. To keep thing simple, we have an insecure form that allows you to log in as anyone:
This is a simple form that takes any arbitrary name, effectively allowing us to log in as anyone (please use an appropriate authentication method for your application). First, let's add to
Main.storyboard. We add a "Login View Controller" scene that's backed by a custom controller
LoginViewController (to be defined). This controller is embedded in a Navigation Controller. Your storyboard should look something like this:
The form is a simple
Stack View with a
username field and a submit button. Let's look at our custom
usernameField is bound to the storyboard's
Username Field, and the login method is bound to the
Login button. When a user clicks login, we check if there's a username and if so, we log in via
Account is a shared object that will store our credentials for future backend interactions. Once the user logs in, we initiate the
UsersSegue, which boots our next scene. We'll see how this is done in a second, but first, let's see how the
Account object logs in.
Here's how we define
First, we set up our shared object that will store our login state in the
userId properties. Note,
apiRoot is the IP address where our backend is running. Please follow the instructions in the
backend to run it. Our
login method uses Alamofire (
AF) to make a
post request to our backend with the user to log in. Upon success, we store the
userId, and then we call
setupStream initializes our Stream Chat client. Here's the implementation:
We call to the
backend with our credentials from
login. We get back a
token, which is a Stream Chat token. This token allows our mobile application to communicate directly with Stream without going through our
backend. We also get an
apiKey, which identifies the Stream account we're using. We need this data to initialize our Stream
Client instance and set the user. Last, we initialize Virgil via
This method requests our Virgil credentials and configures a
VirgilClient class we defined, which wraps Virgil's E3Kit library. Once that's done, we call the
completion to indicate success and allow the application to move on.
Let's see what the beginning of our
VirgilClient implementation looks like:
VirgilClient is a singleton that is set up via
configure. We take the
identity (which is our username) and token, and then we generate a
EThree client uses this callback to get a new JWT token. In our case, we've kept it simple by just returning the same token, but in a real application, you'd likely want to replace this with the rest call to the backend.
We use this token callback and identity to initialize
eThree. We then use this instance to register the user.
Now we're set up to start chatting!
Next, we'll create a view to list users. In the
Main.storyboard we add a
UITableView and back it by our custom
And here's the first few lines of
First, we fetch the users when the view loads. We do this via the
Account instance configured during login. This action simply hits the
/v1/users endpoint. Refer to the source if you're curious. We store the users in a
users property and reload the table view.
Let's see how we configure the table cells:
To render the table correctly we indicate the number of rows via
users.count and set the text of the cell to the user at that index. With this list of users, we can set up a click action on a user's row to start a private 1-on-1 chat with them.
First, add a new blank view to
Main.storyboard backed by a new custom class
EncryptedChatViewController (we'll see its implementation in a minute):
Add a segue between from the user's table row to show the new controller:
With that setup, we can hook into the segue via the
Before transitioning to the new view, we need to set the view controller up. We generate a unique channel id using the user ids. We initialize a
ChannelPresenter from the Stream library, set the type to messaging, and restrict the users. We grab the view controller from the segue and back it with the
ChannelPresenter. This will tell the controller which channel to use. We also tell it what users are communicating.
Let's see how the controller creates this view:
Luckily, Stream comes with excellent UI components out of the box. We'll simply inherit from
ChatViewController to do the hard work of displaying a chat. The presenter we set up tells the Stream UI component how to render. All we need to do now is hook into the message lifecycle to encrypt and decrypt messages on the fly.
Now we're ready to send our first encrypted message. Since we're using Stream's built-in UI, all we need to do is hook into the message sending cycle. We'll do this via the
messagePreparationCallback on the
Upon loading the view, we set up the callback, which is a hook provided by Stream, to make any modifications we'd like to the message before sending it over the wire. Since we want to encrypt the message fully, we grab it and modify the text with the
For this to work, we need to look up the public key of the other user. Since this action requires a call to the Virgil API, it's asynchronous. We don't want to do this during message preparation, so we do it ahead of time via
VirgilClient.shared.prepareUser(otherUser!). Let's see how that method is implemented:
This is relatively simple with Virgil's E3Kit. We find the user's
Card which stores all of the information we need to encrypt and decrypt messages. We'll use this
Card in the
encrypt method during message preparation:
Once again, this is made easy by Virgil. We simply pass the text and the correct user card to
authEncrypt, and we're done! Our message is now ciphertext ready to go over the wire. Stream's library will take care of the rest.
Since we're using the
ChatViewController to render the view, we only need to hook into the cell rendering. We need to decrypt the message text and pass it along. We override the
We make a copy of the message and set the message's text to the decrypted value. In this case, we need to know if it's our message or theirs. First, we'll look at how to decrypt ours:
Since the message was encrypted initially on the device, we have everything we need. We simply ask Virgil E3Kit to decrypt it via
authDecrypt. Decrypting the other user's messages is a bit more work:
In this case, we use the same method, but we pass the user card that we retrieved earlier, which verifies the message's authenticity. Now we can see our full chat:
And that's all! We now have an application that uses end-to-end encryption to protect a user's conversation. Happy coding!