Creating an end-to-end encrypted chat with GetStream.io and seald.io 
Tags: encryption, reactnative, security
Published: 2022-02-23 07:32:57

---
title: Creating an end-to-end encrypted chat with GetStream.io and seald.io 
tags:
   - encryption
   - reactnative
   - security
categories:
  - react-native
authors:
  - sander.goossens
layout: post
published: true 
---

> This post originally appeared on our [`ITP Dev blog`](https://inthepocket.dev/posts/2022-02-15-creating-an-encrypted-chat-with-getstream-and-seald-io/)

Recently, a client came to us asking for a chat solution with encryption support. After some research, we quickly came to a combined solution
of [`GetStream.io`](https://getstream.io) and [`Seald.io`](https://www.seald.io).

![GetStream.io](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ojnbzyk8w50tkjc4geq3.png)
 
`GetStream.io` offers real-time chat messaging with a reliable chat infrastructure and feature-rich SDKs. One of the main selling points of GetStream.io is the
availability of UI components out of the box. With support for React Native, React, Android, Flutter, iOS/Swift,... you can quickly create a chat application without
the hassle of creating every UI interaction yourself.

![Seald.io](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/17ifl3c426v537agxtn1.png) 

`Seald.io` offers end-to-end encryption in Europe. The solution benefits from a security visa (CSPN) issued by the ANSSI, which ensures you a robust security model.
CSPN is the certification of Seald's level of robustness, based on compliance analysis and advanced penetration testing.
End-to-end encryption is the most secure technology available, but it is also the most complex and time-consuming to implement (key management, user account recovery, etc.).
To simplify its adoption, Seald has developed an SDK associated with an API, which allows you to add end-to-end encryption to your applications in a few lines of code, without any prior knowledge of cryptography.

*Because of the APIs and components SDKs made available by Stream, we can hook the end-to-end encryption of Seald into it.*

Enough introduction, let's see what we are going to build and how we approached this!

***
## Goals
***

- Chat messages should only be readable by the members of a conversation
- Chat messages should not be readable in the Stream admin dashboard

***
## Services
***

- `Messaging API` custom nodeJS backend to handle all GetStream and Seald server side functionality. Has a postgreSQL database where we store seald users.
- `Users API` custom nodeJS backend to handle everything conserning users.

<br/>
<br/>

<center>
<img src="https://c.tenor.com/4eF0XKHqDB4AAAAC/meeting-bored.gif">
</center>

<br/>
<br/>

***
## Now to the actual implementation
***

Let's start our application with a `registration` screen where the user can enter their details. When a user successfully registers, we can authenticate him/her with our authentication provider.

<center>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/toonxp56vkmodywnsmmy.PNG" style="width: 25rem;">
</center>


*The authentication providers returns with a JWT code that can be used across our full application.*

```json
{
  "accessToken":  "eyJ2ZXIiOiIxLjAiLCJraWQiOiIzYWMxMTjYwZi0yMzhm..."
}
```

We would like to connect the user to the GetStream servers, thus we need to fetch a JWT token from GetStream. An endpoint in our Messaging API will handle this.
Use the user ID from your authentication provider to create a token at GetStream.

```ts
import { StreamChat } from 'stream-chat';
const serverClient = StreamChat.getInstance(STREAM_KEY, STREAM_SECRET);

export async function getMessagingToken(id: string) {
  return serverClient.createToken(id);
}
```

We will store this `messaging token` inside our react-native application. *We are using <a href="https://www.npmjs.com/package/zustand" target="_blank">`Zustand`</a> ❤️ for this.*
Using this token, we are now able to connect a user to our GetStream client.

```ts
 const client = StreamChat.getInstance(config.stream.apiKey);

 const init = useCallback(async () => {
   try {
      // First disconnect any user that was still connected.
      await client.disconnectUser();
      await client.connectUser(
        {
          id: connectedUserId,
        },
        // Use the JWT token received from our messaging API endpoint.
        messagingToken.accessToken,
      );
    } catch (error) {
      logException(error);
    }
  }, [messagingToken, connectedUserId, client]);
```
*We call this init function inside our ChatProvider that's wrapping our screens.*

Let's continue to the next step in our onboarding process. We now need to initialise our `2FA` with seald.
We created a screen where the user can enter his/her **phone number**.

<center>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ch64dw9bhnrenqqdlr2t.png" style="width: 25rem;">
</center>

When a user enters their phone number, we need to send a challenge that the user can enter on the following screen.
We created a custom hook `useTwoFactorAuth` to handle the setup of 2FA.

For doing actual API calls, we use react-query. I will not deep dive into every custom hook, the 3 hooks below are fetching some data with react-query
from our API endpoints.

```ts
  const { signup, isLoading } = useSignupSeald();
  const { fetchMe, isLoading: isLoadingFetchMe } = useMeSeald();
  const { sendChallenge, isLoading: isLoadingSendChallenge } = useSendChallenge();
```

When a user does not yet exist, we call the `signup` endpoint. This endpoint will make sure that the user is saved in our postgreSQL database and that a twoManRuleKey is created and stored in our database. To know more about the protection with the 2-man-rule, you can check the <a href="https://docs.seald.io/en/sdk/example/2-man-rule.html#explanation" target="_blank">`docs`</a>.

```ts
  const setup2FA = async (phoneNumber: string) => {
    let user: OnboardedUser;

    try {
      // The messaging API will be called to fetch an existing seald user, this is needed because the login will also execute this setup2FA hook.
      user = await fetchMe();
      // It's possible that the user early killed the application while the signup was still busy. If we were not able to
      // store an activation key, we need to signup the user again to receive one needed for the initiation of the identity.
      if (!user.activationKey) {
        user = await signup();
      }
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
    } catch (error: any) {
      // User does not exist
      if (error?.error?.type === MessagingApiErrorType.USER_NOT_FOUND) {
        user = await signup();
      }
    }
  };
```

For seald to be able to work properly, we need to generate a user licence token in our backend. See <a href="https://docs.seald.io/en/sdk/example/user-licence-token.html#licence-token-generation-function" target="_blank">`docs`</a> for more information on how to do this. Whenever the endpoint to fetch the user or to signup the user is called, a licence token will be generated
and returned by our API.

When an existing user returns, the `fetchMe` endpoint will return the following response:

```json
{
   "id":"0015r00000OxGgIAAV",
   "isEnrolled":true,
   "sealdId":"TESTING_0015r00000OxGgIAAV",
   "twoManRuleKey":"VCl2Z0U+LYZ6Fq2rjm40PFrYWlrLDIsSRgGufKo9wJGlxDAU+5mUwji21g2G86GzN3dLKsrdWmYbPZn0QTGa9g=="
}
```

When a new user is created during signup, the response from the `signup` endpoint will be:

```json
{
   "activationKey":"dbbb72c5-b2c5-47db-ad4c-5dfec91918df:5b1f82d5f3cb073c0103a4aa55bdd900777a4b3b4b89b99d906af533f3358a6e:7f24176eff547227984710ac5b2d858b3a78af509225f5b6cdc41197254dd81a02b16d893b176ffd70c952b87c4ba95a1f98b9f0bc3d0926f117e832fa45a9f3",
   "id":"0015r00000Q0DQcAAN",
   "isEnrolled":false,
   "sealdId":"TESTING_0015r00000Q0DQcAAN",
   "twoManRuleKey":"miSTd8ad7FrtvNSMee+uLDLgj+tYCPf9iENm3CzsytILSw5YuMI1TEOtEF7sU46f4qa6KVF+wb3tU1cdbLzTBA=="
}
```

Now that our user is created, we can send the challenge to the user and initiate the seald identity with the activation key.

```ts
    if (user) {
      // The activation key is the user licence token from seald.
      const { sealdId, activationKey } = user;

      // Create session in seald, send challenge to user.
      await sendChallenge({ phoneNumber });

      if (sealdId && activationKey) {
        await seald.initiateIdentity(sealdId, activationKey);
      }
    }
```

The sendChallenge will call our Messaging API endpoint that implements the sending of a challenge according to the <a href="https://docs.seald.io/en/sdk/api-ssks-2mr/#headers-of-the-requests" target="_blank">specifications</a>.
Seald will create a "session" and this session will be required in the save or retrieve of our seald identity.

```json
{
   "mustAuthenticate": true,
   "twoManRuleKey":"miSTd8ad7FrtvNSMee+uLDLgj+tYCPf9iENm3CzsytILSw5YuMI1TEOtEF7sU46f4qa6KVF+wb3tU1cdbLzTBA==",
   "twoManRuleSessionId":"c1c2b55f-f119-4a88-8a0d-e95512e3c667"
}
```

We can now continue to our last screen for entering the challenge.

<center>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hecjmwjlg1hf7qibsyny.png" style="width: 25rem;">
</center>


A custom hook will be called when the challenge was entered:

```ts

export const useSaveOrRetrieveSealdIdentity = () => {
  const { updateUser } = useUpdateUser();
  const { enroll } = useEnroll();

  const sealdId = useStore(state => state.sealdId);
  const twoManRuleSessionId = useStore(state => state.twoManRuleSessionId);
  const twoManRuleKey = useStore(state => state.twoManRuleKey);
  const mustAuthenticate = useStore(state => state.mustAuthenticate);
  const userEnrolled = useStore(state => state.isEnrolled);

  return {
    saveOrRetrieveIdentity: async (phoneNumber: string, challenge: string) => {
      // Store phone number for later login purposes.
      await updateUser({ c_recoveryPhoneNumber: phoneNumber });

      // If user was already enrolled, the identity already exists so we need to retrieve it.
      // SSKS will be called, and the identity will be retrieved.
      if (userEnrolled) {
        await seald.retrieveIdentity(
          sealdId,
          twoManRuleSessionId,
          twoManRuleKey,
          challenge,
          phoneNumber,
        );
      } else {
        await seald.saveIdentity(
          phoneNumber,
          twoManRuleKey,
          sealdId,
          twoManRuleSessionId,
          mustAuthenticate,
          challenge,
        );

        if (!userEnrolled) {
          enroll();
        }
      }
    },
  };
};
```

We now have established a secure identity and can use this identity to create secure sessions between two users 🙌

<center>
  <img src="https://c.tenor.com/HJ0iSKwIG28AAAAC/yes-baby.gif" style="width: 25rem;">
</center>

<br/>
<br/>

***
## Creating a secure chat
***

GetStream.io offers us very fine UI components that easily integrate in our react-native application. We have modified some components of GetStream, but in this example
I will only show the necessary things.

When creating a screen for the chat, you can start with a basis implementation like this:

```tsx
  import { Channel, Chat } from 'stream-chat-react-native';

  const client = StreamChat.getInstance(config.stream.apiKey);
  const channel = client.channel("messaging", id, ["member1", "member2"]);

  return (
    <Chat client={client}>
        <Channel channel={channel} /> // Channel you created between two members.
    </Chat>
  );
```

Without encrypting/decrypting any messages, the chat will look like this:

<center>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qjknodfd0jtg8vd3zj61.png" style="width: 25rem;">
</center>

But we want to be able to send messages encrypted. Therefore, we need to override the `doSendMessageRequest` method.
As you can see, we encrypt our message using an encryption session. This encryption session needs to be created when creating your channel.

When an encryption session between two users is created, we store the session ID on our channel metadata in getstream, so it can be reused to fetch an encryption session.

```tsx
  const channel = client.channel("messaging", id, ["member1", "member2"]);
  await channel.watch();

  let session: EncryptionSession;

  if (channel.data?.session_id) {
    session = await seald.retrieveEncryptionSession(channel.data.session_id);
  } else {
    session = await seald.createEncryptionSession(members.map(member => member.sealdId));
    await channel.updatePartial({ set: { session_id: session.sessionId } });
  }

  const sendMessage = async (_channelId: string, message: Message) => {
    const encryptedText = await encryptionSession.encryptMessage(message.text || '');
    return channel.sendMessage({ ...message, text: encryptedText });
  };
  
  return (
    <Chat client={client}>
        <Channel
          channel={channel}
          doSendMessageRequest={sendMessage}
        />
    </Chat>
  );
```

If we send another message now, you can see that the message text is now encrypted and unreadable.

<center>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mokbo5lxeleivqvaj8gi.PNG" style="width: 25rem;">
</center>

We need to override the `MessageText` component to show an unencrypted message.

```tsx
  return (
    <Chat client={client}>
        <Channel
          channel={channel}
          doSendMessageRequest={sendMessage}
          MessageText={(props: MessageTextProps) => (
            <DecryptedMessageText
              {...props}
              channel={channel}
              session={encryptionSession}
              onError={handleDecryptionError}
              onFinished={handleDecryptionEnd}
            />
          )}
        />
    </Chat>
  );
```

Our `DecryptedMessageText` component will use our encryption session to decrypt the message.

```tsx
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { EncryptionSession } from '@seald-io/sdk/lib/main';
import type { MessageTextProps, MessageType as Message } from 'stream-chat-react-native';

import type { LocalChannel } from 'core/chat/types';

interface Props extends MessageTextProps {
  channel: LocalChannel;
  session: EncryptionSession;
}

const decryptMessage = async (text: string, session: EncryptionSession) => {
  try {
    // If we have  JSON, it can be decrypted.
    JSON.parse(text);
    try {
      if (!session) {
        throw new Error('EncryptionSession is undefined');
      }
      return await session.decryptMessage(text);
    } catch (err) {
      logException(err);
      return false;
    }
  } catch (err) {
    // Otherwise it's just plain text
    return text;
  }
};

const DecryptedMessageText = ({
  channel,
  session,
  message,
  renderText,
  theme,
  ...rest
}: Props) => {
  const { t } = useTranslation();
  const decryptedMessage = useRef<Message>(message);

  useEffect(() => {
    const decrypt = async () => {
      if (message.text) {
        const text = await decryptMessage(message.text, session);
        if (text === false) {
          // Handle errors
          return;
        }

        decryptedMessage.current = {
          ...message,
          text,
        };
      }
    };

    decrypt();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [message, session]);

  return renderText({
    ...rest,
    message: decryptedMessage.current,
  });
};

export default DecryptedMessageText;
```

*Our message is now `decrypted`, and again visible for the members of the conversation.*

<center>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ie2gj3bldlaqex1qxy17.PNG" style="width: 25rem;">
</center>


When we take a look at the GetStream.io dashboard, we can also see that our messages are not readable, and so are securely encrypted 💪

<center>
![Dashboard getstream](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ddbpu7nfohvkqs06wfma.png)
</center>

<br/>
<br/>

***
## Conclusion
***

<center>
<img src="https://c.tenor.com/mYI35XJyfloAAAAC/micdrop-jonstewart.gif">
</center>