How to Build an End-to-End Encrypted Chat with Seald and PubNub

Written by seald | Published 2023/09/20
Tech Story Tags: web-development | seald | pubnub | seald-sdk | end-to-end-encryption | how-to-build-a-chat-app | zero-trust | good-company

TLDRThis article guides you on building an end-to-end encrypted chat using PubNub and the Seald SDK. It highlights the importance of end-to-end encryption for privacy and security, and explains why Seald.io is preferable over PubNub's native encryption for key management. The article covers the implementation of one-to-one and group chat rooms, user authentication, and real-time access management using PubNub. It also outlines the process of integrating Seald for robust encryption and decryption of messages and files within the chat application. Overall, it provides a comprehensive guide to creating a secure chat application.via the TL;DR App

Hello šŸ‘‹

Today, let's discoverĀ how to build anĀ end-to-end encrypted chatĀ withĀ PubnubĀ and theĀ seald SDK.

šŸ‘‰ A fully working example of how to use PubNub and Seald can be found here:Ā https://github.com/seald/pubnub-example-project

What's Pubnub?

PubNub is a real-time communication service that can be integrated into most applications. Reliable and scalable, it can easily be integrated with the most common frameworks.

What's Seald?

Seald.io offers an SDK that allows you to carry outĀ end-to-end encryption, with advanced management features,Ā without any prior cryptographic knowledge.Ā This SDK can be integrated into web, backend, mobile, or desktop applications.

Content Overview

  • Why use end-to-end encryption?
  • Why use Seald.io instead of PubNub encryption hook ?šŸ‘€
  • Goals šŸ†
  • Implementation šŸ§ 
  • Adding end-to-end encryption with Seald šŸ”’šŸ’¬
  • Conclusion āœ…

Why use end-to-end encryption? šŸ”’

End-to-end encryption offersĀ the highest level of privacy and security.Ā It allows encrypting sensitive data as soon as they are collected. An early encryption will reduce the attack surface of your app. Another advantage is that you can preciselyĀ manage who can access the data.Ā This will also protect when it is not in your scope,Ā like third-party services.

End-to-end encryption allows youĀ to keep control at all times,Ā when your data is in transit, when it is at rest, and even when it is not in your hand. Thus, it offers far wider protection than other encryption technologies (TLS, full-disk encryption, ...).

Whenever you face a case whereĀ compliance is importantĀ (GDPR, HIPAA, SOC-2,...) or where you haveĀ sensitive dataĀ (medical, defense,...), end-to-end encryption is a must-have. But even for more commonplace data, it's good practice to have. A data breach is a devastating event that is becomingĀ more and more frequent.

Why use Seald.io instead of PubNub encryption hook ?šŸ‘€

PubNub SDK offers a simple encryption hook, using theĀ cipherKeyĀ argument when instantiating its SDK. Doing so will ensure that all uploaded messages are encryptedĀ before being sent.Ā However, you have to do the key management yourself, which isĀ the hardest partĀ of an end-to-end encrypted system.

Vulnerabilities rarely come from the encryption itself, but most oftenĀ from keys being leakedĀ through flaws in the security model.

Using a third-party service for your security allows youĀ to not have a single point of failure.Ā Seald.io proposes a robust security model,Ā certified by the ANSSI,Ā with real-time access-management control, user revocation and recovery, 2FA, and more.

Goals šŸ†

This article explains how to integrate Seald with PubNubĀ step-by-step,Ā in order to secure your chat with end-to-end encryption. We will buildĀ an example messaging app,Ā with the following features:

  • One-to-one and group chat rooms.

  • Each member has a dedicated chat room with every other user.

  • Anyone can create a group chat room with multiple other users.

  • Use end-to-end encryption for every message and file sent.

  • Allow real-time access management to chats.

Implementation šŸ§ 

Set up a PubNub account šŸ‘¤

To get started, you will needĀ a PubNub account.Ā You can sign upĀ here. Once logged in on your dashboard, you should see thatĀ a demo appĀ with a demo keyset has been created:

Select the demo keyset,Ā and scroll to the configuration tab. For our demo, we need to activateĀ FilesĀ andĀ ObjectsĀ permissions. For theĀ ObjectĀ permission, we will use the following events:Ā User Metadata Events,Ā Channel Metadata EventsĀ andMembership Events.

Once the keyset is created and configured,Ā we need to copy it to our frontend.

Let's create a JSON file on theĀ src/Ā folder, calledĀ settings.json. We will use this file for all the API keys we will need. Starting with the PubNub keyset:

{
  "PUBNUB_PUB_KEY": "pub-c-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
  "PUBNUB_SUB_KEY": "sub-c-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
}

Building a basic chat using PubNub šŸ’¬

We will use PubNub for almostĀ every backend task.Ā Our backend will only handle user sign-up/sign-in, and use a minimalist user model with only an ID, a name, and an email.


On the front side, we need a small authentication interface:

Once the user has an account, the first thing they need is an instance of the PubNub SDK.

To identify the user on Pubnub,Ā we need to provide a UUID.

To keep things simple, we will use the same ID as on our backend:

/* frontend/src/App.js */

import settings from './settings.json' // our settings file for API keys

/*
...
*/

const pubnub = new PubNub({
    publishKey: settings.PUBNUB_PUB_KEY,
    subscribeKey: settings.PUBNUB_SUB_KEY,
    uuid: currentUser.id
})

To keep our backend as simple as possible, we will use PubNub's user metadata to exchange users' info.

Just after the SDK instantiation,Ā we simply call PubNubĀ setUUIDMetadataĀ function:

/* frontend/src/App.js */

await pubnub.objects.setUUIDMetadata({
  uuid: currentUser.id,
  data: {
    email: currentUser.emailAddress,
    name: currentUser.name
  }
})

Getting initial app state šŸŒ±

The first thingĀ to do with PubNub is to retrieve all existing members and store them in our local data store:

/* frontend/src/App.js */

const existingMembers = await pubnub.objects.getAllUUIDMetadata()
dispatch({
  type: SET_USERS,
  payload: {
    users: existingMembers.data.map(u => new User({ id: u.id, name: u.name, emailAddress: u.email }))
  }
})

Each chat room will correspond to a PubNub channel. We will also add some metadata to each channel:

  • ownerId: The ID of the user who created the room.

  • one2one: A boolean to differentiate direct messaging rooms and group rooms.

  • archived: A boolean, to hide a deleted group room.

TheĀ ownerIdĀ metadata will be used later when adding the Seald SDK. PubNub doesn't have an ownership concept, but Seald does. It will define who can add or remove users from a channel. It basically defines a group administrator.

We will start byĀ retrieving existing chat rooms.Ā We will also need the room metadata, so we need to include custom fields. Then, we need to filter out archived rooms and send everything to our data store.

Finally, weĀ subscribeĀ to the PubNub channel associated with the room, so we will receive new messages:

/* frontend/src/App.js */

// Retrieve rooms of which we are members
const memberships = await pubnub.objects.getMemberships({
  include: {
    customChannelFields: true
  }
})
const knownRooms = []
// For each room, retrieve room members
for (const room of memberships.data.filter(r => !r.channel.custom.archived)) {
  const roomMembers = await pubnub.objects.getChannelMembers({ channel: room.channel.id })
  knownRooms.push(new Room({
    id: room.channel.id,
    name: room.channel.name,
    users: roomMembers.data.map(u => u.uuid.id),
    ownerId: room.channel.custom.ownerId,
    one2one: room.channel.custom.one2one
  }))
}
// Store rooms in our data store
dispatch({
  type: SET_ROOMS,
  payload: {
    rooms: knownRooms
  }
})
// Subscribe to channels to get new messages
pubnub.subscribe({ channels: knownRooms.map(r => r.id) })

Now we have fetched all the rooms we are in. We need one last thing to finish app initialization: ensuring we have aĀ one2oneĀ room with every other member, including newly registered ones.

For every newly found user,Ā we will createĀ a new room and send a hello message.

Then, we will set the room's metadata, and subscribe to it:

/* frontend/src/App.js */

// Ensure that we have a one2one room with everyone
const one2oneRooms = knownRooms.filter(r => r.one2one)
for (const m of existingMembers.data.filter(u => u.id!== currentUser.id)) {
    if (!one2oneRooms.find(r => r.users.includes(m.id))) {
      // New user found: generating a new one2one room
      const newRoomId = PubNub.generateUUID()
      const newRoom = new Room({
            id: newRoomId,
            users: [currentUser.id, m.id],
            one2one: true,
            name: m.name,
            ownerId: currentUser.id
          })
      // Add the new room to our local list
      dispatch({
        type: EDIT_OR_ADD_ROOM,
        payload: {
          room: new Room({
            id: newRoomId,
            users: [currentUser.id, m.id],
            one2one: true,
            name: m.name, ownerId: currentUser.id
          })
        }
      })
      // Publish a "Hello" message in the room
      await pubnub.publish({
        channel: newRoomId,
        message: {
          type: 'message',
          data: (await sealdSession.encryptMessage('Hello šŸ‘‹'))
        }
      })
      // Subscribe to the new room
      pubnub.subscribe({ channels: [newRoomId] })
      await pubnub.objects.setChannelMetadata({
        channel: newRoomId,
        data: {
          name: 'one2one',
          custom: {
            one2one: true,
            ownerId: currentUser.id,
          },
        }
      })
      await pubnub.objects.setChannelMembers({
        channel: newRoomId,
        uuids: [currentUser.id, m.id]
      })
    }
}

Once all that is done, our initial app stateĀ is fully defined.Ā However, we need to keep it up to date.

It can be done simply by adding an event listener forĀ membershipĀ events:


/* frontend/src/App.js */

pubnub.addListener({
  objects: async function(objectEvent) {
    if (objectEvent.message.type === 'membership') {
      if (objectEvent.message.event === 'delete') { // User is removed from a room
        /*
        Removing the room from store...
        */
      }
      if (objectEvent.message.event === 'set') { // User is added to a room
        const metadata = await pubnub.objects.getChannelMetadata({ channel: objectEvent.message.data.channel.id })
        const roomMembers = (await pubnub.objects.getChannelMembers({ channel: objectEvent.message.data.channel.id })).data.map(u => u.uuid.id)
        /*
        Adding new room to store + subscribing to new room channel...
        */
      }
    }
  }
})
pubnub.subscribe({ channels: [currentUser.id] }) // channel on which events concerning the current user are published

We can now have a look at aĀ one2oneĀ chat room itself. Then we will handle group rooms.

Receiving and sending messages in a chat room šŸ“©

In aĀ chat.jsĀ file, we will have all the logic to display the messages of a chat room.

To initialize this room,Ā the only thing we need to do is to fetch all pre-existing messages.

It can be done simply by knowing the room ID:

/* frontend/src/components/Chat.jsx */

const fetchedMessages = (await pubnub.fetchMessages({ channels: [currentRoomId] })).channels[currentRoomId]

We can subscribe to the channel to get new messages, and add a listener to display them in real-time:

/* frontend/src/components/Chat.jsx */

pubnub.addListener({ message: handleReceiveMessage })
pubnub.subscribe({ channels: [currentRoomId] })

To send a message, we simply need to publish it on the channel:

/* frontend/src/components/Chat.jsx */

const handleSubmitMessage = async e => {
  /* Some checks that the room is in a correct state... */
  await pubnub.publish({
    channel: state.room.id,
    message: {
      type: 'message',
      data: state.message
    }
  })
}

To send a file,Ā we will first upload it to PubNub. Then we will get the uploaded file URI, and publish it as a message in the chat room:

/* frontend/src/components/UploadButton.jsx */

// Upload Encrypted file
const uploadData = await pubnub.sendFile({
  channel: room.id,
  file: myFile,
  storeInHistory: false
})
const fileURL = await pubnub.getFileUrl({ id: uploadData.id, name: uploadData.name, channel: room.id })
await pubnub.publish({
  channel: state.room.id,
  message: {
    type: 'file',
    url: fileURL,
    fileName: await sealdSession.encryptMessage(selectedFiles[0].name)
  }
})

Managing group chat šŸ‘Øā€šŸ‘©ā€šŸ‘¦ā€šŸ‘¦

To create and manage groups, we will need an interface for selecting users:

Once the group members have been selected,Ā we can create a PubNub channel for our room, and then set metadata and membership for the channel. The code is very similar to what is done for one2one rooms, so we will not repeat it here.

We now have a full chat app.Ā Let's add end-to-end encryption for every message!

Adding end-to-end encryption with Seald šŸ”’šŸ’¬

Set up a Seald account šŸ‘¤

To start with Seald,Ā create a free trial accountĀ here

When landing on the Seald dashboard, a few URLs and API tokens are displayed.


Get the following elements:

  • appId
  • apiURL
  • keyStorageURL

We will add these keys to ourĀ settings.json:

{
  "PUBNUB_PUB_KEY": "pub-c-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
  "PUBNUB_SUB_KEY": "sub-c-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
  "SEALD_APP_ID": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
  "SEALD_API_URL": "https://api.staging-0.seald.io",
  "SEALD_KEYSTORAGE_URL": "https://ssks.staging-0.seald.io"
}

To be able to use the Seald SDK,Ā every user needs a licence JWT when signing-up.

These JWT needs to be generated on the backend using a secret and a secret ID.

From the dashboard landing page, copy the JWT secret and its associated ID inĀ backend/settings.json:

{
  "SEALD_JWT_SECRET_ID": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
  "SEALD_JWT_SECRET": "XXXXXXXXXXXXXXXXXXXXXXXX"
}

During the signup API call, we will generate a Seald licence JWT, and return it back:

/* backend/routes/account.js */

const token = new SignJWT({
  iss: settings.SEALD_JWT_SECRET_ID,
  jti: uuidv4(), /// Random string with enough entropy to never repeat.
  iat: Math.floor(Date.now() / 1000), // JWT valid only for 10 minutes. `Date.now()` returns the  in milliseconds, this needs it in seconds.
  scopes: [3], // PERMISSION_JOIN_TEAM
  join_team: true
})
  .setProtectedHeader({ alg: 'HS256' })

const signupJWT = await token.sign(Buffer.from(settings.SEALD_JWT_SECRET, 'ascii'))

For more information about this, seeĀ our documentation article about JWT.

Then, we needĀ to install the Seald SDK.

We also need to install a pluginĀ to identify users on the Seald server. To do so, we will use theĀ sdk-plugin-ssks-passwordĀ package.

This plugin allows for simple password authentication of our users:

npm i -S @seald-io/sdk @seald-io/sdk-plugin-ssks-password

Instantiating the Seald SDK šŸ’”

Then, we will create aĀ seald.jsĀ file. In this file, we will start by creating a function to instantiate the Seald SDK:

/* frontend/src/services/seald.js */

import SealdSDK from '@seald-io/sdk-web'
import SealdSDKPluginSSKSPassword from '@seald-io/sdk-plugin-ssks-password'
import settings from './settings.json'

let sealdSDKInstance = null

const instantiateSealdSDK = async () => {
  sealdSDKInstance = SealdSDK({
    appId: settings.SEALD_APP_ID,
    apiURL: settings.SEALD_API_URL,
    plugins: [SealdSDKPluginSSKSPassword(settings.SEALD_KEYSTORAGE_URL)]
  })
}

Creating and retrieving Seald identities šŸ”‘

InĀ seald.js, we will also add two function: one to create an identity and one to retrieve one.Ā To create an identity,Ā we also need the licence JWT returned at account creation:

/* frontend/src/services/seald.js */

export const createIdentity = async ({ userId, password, signupJWT }) => {
  await instantiateSealdSDK()
  await sealdSDKInstance.initiateIdentity({ signupJWT })
  await sealdSDKInstance.ssksPassword.saveIdentity({ userId, password })
}

export const retrieveIdentity = async ({ userId, password }) => {
  await instantiateSealdSDK()
  await sealdSDKInstance.ssksPassword.retrieveIdentity({ userId, password })
}

During our signup and sign-in flows, we just need to call these functions after the user has logged in.

At this point, every time our user is connected, he has a working Seald SDK,Ā ready to encrypt and decrypt!

Warning: For proper security, the password should be pre-hashed before being sent to the authentication server. For more details on this, please seeĀ the paragraph about password authentication in our documentation.

Start encrypting and decrypting messages šŸ”’šŸ”“

Each chat room will be associated with anĀ encryptionSessionĀ on the Seald SDK.

Every time we create a chat room,Ā we simply have to add one line to create the encryption session,Ā then use it:

/* frontend/src/App.js */

// Create a Seald session
const sealdSession = await getSealdSDKInstance().createEncryptionSession(
  { userIds: [currentUser.id, m.id] },
  { metadata: newRoomId }
)
// Publish a "Hello" message in the room
await pubnub.publish({
  channel: newRoomId,
  message: {
    type: 'message',
    data: (await sealdSession.encryptMessage('Hello šŸ‘‹'))
  }
})

Note that our user is included by default as a recipient of theĀ encryptionSession.

When accessing a room, we need to get the correspondingĀ encryptionSession. It can be retrieved from any encrypted messages or file. Once we have it, we'll keep it in component reference.

Then we can simply useĀ session.encryptMessage ,session.encryptFile,Ā session.decryptMessage andĀ session.decryptFileĀ functions.

Let's start with the messages. To send a message:

/* frontend/src/components/Chat.js */

const handleSubmitMessage = async m => {
  /* Some validation that we are in a valid room... */

  // if there is no encryption session set in cache yet, create one
  // (should never happen, as a "Hello" is sent on room creation)
  if (!sealdSessionRef.current) {
    sealdSessionRef.current = await getSealdSDKInstance().createEncryptionSession(
      { userIds: state.room.users },
      { metadata: state.room.id }
    )
  }
  // use the session to encrypt the message we are trying to send
  const encryptedMessage = await sealdSessionRef.current.encryptMessage(state.message)
  // publish the encrypted message to pubnub
  await pubnub.publish({
    channel: state.room.id,
    message: {
      type: 'message',
      data: encryptedMessage
    }
  })

  /* Some cleanup... */
}

And when we receive a message:

/* frontend/src/components/Chat.js */

const decryptMessage = async m => {
  /* Filter out files... */
  let encryptedData = m.message.data

  if (!sealdSessionRef.current) { // no encryption session set in cache yet
    // we try to get it by parsing the current message
    sealdSessionRef.current = await getSealdSDKInstance().retrieveEncryptionSession({ encryptedMessage: encryptedData })
    // now that we have a session loaded, let's decrypt
  }
  const decryptedData = await sealdSessionRef.current.decryptMessage(encryptedData)
  // we have successfully decrypted the message
  return {
    ...m,
    uuid: m.uuid || m.publisher,
    value: decryptedData
  }
  /* Some error handling... */
}

/* Other stuff... */

const handleReceiveMessage = async m => {
  const decryptedMessage = await decryptMessage(m)
  setState(draft => {
    draft.messages = [...draft.messages, decryptedMessage]
  })
}

Also, we use thisĀ decryptMessageĀ function to decrypt all messages already in the session when opening a room:

/* frontend/src/components/Chat.js */

const fetchedMessages = (await pubnub.fetchMessages({ channels: [currentRoomId] })).channels[currentRoomId]
const clearMessages = fetchedMessages ? await Promise.all(fetchedMessages.map(decryptMessage)) : []

And now for files. To upload a file:

/* frontend/src/components/UploadButton.js */

// Encrypt file
const encryptedBlob = await sealdSession.encryptFile(
  selectedFiles[0],
  selectedFiles[0].name,
  { fileSize: selectedFiles[0].size }
)
const encryptedFile = new File([encryptedBlob], selectedFiles[0].name)
// Upload Encrypted file
const uploadData = await pubnub.sendFile({
  channel: room.id,
  file: encryptedFile,
  storeInHistory: false
})

And to decrypt a file:

/* frontend/src/components/Message.js */

const onClick = async () => {
  if (state.data.type === 'file') {
    const response = await fetch(state.data.url)
    const encryptedBlob = await response.blob()
    const { data: clearBlob, filename } = await sealdSession.decryptFile(encryptedBlob)
    const href = window.URL.createObjectURL(clearBlob)
    /* Create an <a> element and simulate a click on it to download the created objectURL */
  }
}

Managing group members šŸ‘Øā€šŸ‘©ā€šŸ‘¦

Group chats will also have theirĀ encryptionSession. Every time a group is created, we need to create one:

/* frontend/src/components/ManageDialogRoom.js.js */

// To create the encryptionSession
const sealdSession = await getSealdSDKInstance().createEncryptionSession(
  { userIds: dialogRoom.selectedUsersId },
  { metadata: newRoomId }
)

Then,Ā every time we modify group members,Ā we will need to add or remove them from it:

/* frontend/src/components/ManageDialogRoom.js.js */

// we compare old and new members to figure out which ones were just added or removed

const usersToRemove = dialogRoom.room.users.filter(id => !dialogRoom.selectedUsersId.includes(id))
const usersToAdd = dialogRoom.selectedUsersId.filter(id => !dialogRoom.room.users.includes(id))

if (usersToAdd.length > 0) {
  // for every added user, add them to the Seald session
  await dialogRoom.sealdSession.addRecipients({ userIds: usersToAdd })
  // then add them to the pubnub channel
  await pubnub.objects.setChannelMembers({
    channel: dialogRoom.room.id,
    uuids: usersToAdd
  })
}

if (usersToRemove.length > 0) {
  // for every removed user, revoke them from the Seald session
  await dialogRoom.sealdSession.revokeRecipients({ userIds: usersToRemove })
  // then remove them from the pubnub channel
  for (const u of usersToRemove) {
    await pubnub.objects.removeMemberships({
      channels: [dialogRoom.room.id],
      uuid: u
    })
  }
}

Conclusion āœ…

Once that's done, we're finished!

We managedĀ to integrate Seald into PubNubĀ with only a few lines of code.

Now thatĀ the chat is end-to-end encrypted,Ā you can assure your users that their data will remain confidential,Ā even in case of data breaches.

As always,Ā donā€™t hesitate toĀ contact usĀ if you need any additional guidance.

Canā€™t wait to see what you've built šŸ„³.

Also published here.


Written by seald | Seald helps developers integrate end-to-end encryption into their applications in minutes, not months.
Published by HackerNoon on 2023/09/20