Skip to content

Quick-start

To get started quickly, we'll integrate the encryption & decryption features directly into the sample project, by password protecting the identity.

The branch on which this step is based is master, the end result is 1-quick-start.

Installation

On the front-end, we will use the web version of the SDK @seald-io/sdk with the password identity protection module @seald-io/sdk-plugin-ssks-password:

shell
cd frontend
npm install --save @seald-io/sdk @seald-io/sdk-plugin-ssks-password

On the back-end, we will need to generate JSON Web Tokens. To do this, we install the jose module:

shell
cd ../backend
npm install --save jose

Configuration

To configure the SDK, you have to connect to your administration dashboard and get the 5 following elements:

  • APP_ID: unique UUID for your application;
  • JWT_SHARED_SECRET_ID : unique UUID of the shared secret;
  • JWT_SHARED_SECRET : shared secret corresponding to the JWTSecret used to generate JWT.
  • API_URL: URL of the API server to use;
  • KEY_STORAGE_URL: URL of the identity storage server to use.

TIP

JWT secrets can be generated in the settings, tab JWT secrets.

For more information refer to this documentation.

TIP

To create a developer account, follow the first steps.

To instantiate the SDK, we proceed as follows:

ts
/* frontend/src/services/seald.ts */
import SealdSDKConstructor, { type SealdSDK } from '@seald-io/sdk'
import SealdSDKPluginSSKSPassword from '@seald-io/sdk-plugin-ssks-password'
/* ... */

let sealdSDKInstance: SealdSDK

const instantiateSealdSDK = async () => {
  sealdSDKInstance = SealdSDK({
    appId: await getSetting('APP_ID'),
    apiURL: await getSetting('API_URL'),
    plugins: [SealdSDKPluginSSKSPassword(await getSetting('KEY_STORAGE_URL'))]
  })
}

export const getSealdSDKInstance = (): SealdSDK => sealdSDKInstance

TIP

Since the SDK instance is shared across an entire application in React, it is better to store it in a variable in a seald.js service file and expose a getter rather than storing it in an immutable variable.

Seald identity Management

When a Seald identity is created, it must be associated with the developer account, and also with the user in the application in which the SDK is integrated.

To do this, we need to generate a JSON Web Token called signupJWT from the JWT_SECRET and the JWT_SECRET_ID. This signupJWT will be consumed at the initiateIdentity, which will return a unique sealdId for the user, to be saved on the backend.

Generating a signupJWT

The generation of a signupJWT is done by your backend, and is described in this dedicated guide.

You'll find examples of how to create signupJWT in the most common backend languages in this section

Your backend must generate a signupJWT during the account creation API call, then return the token in the call response.

WARNING

It is imperative not to reveal the JWT_SHARED_SECRET publicly.

It authenticates the actions of the application server to the Seald server. If it were made public, these actions could be performed freely by an attacker.

For example, an attacker could create new Seald users who would be invoiced and reassign your users' connectors to Seald accounts they control, which could lead to a data leak.

You can refer to the guide dedicated to JWT generation.

js
/* backend/utils.js */
// utils for `generateSignupJWT`
const randomString = (length = 10) => randomBytes(length)
  .then(randomBytes => randomBytes
    .toString('base64')
    .replace(/[^a-z0-9]/gi, '')
    .slice(0, length)
  )

const generateSignupJWT = async () => {
  const token = new jose.SignJWT({
    iss: settings.JWT_SHARED_SECRET_ID,
    jti: await randomString(), // So the JWT is only usable once.
    iat: Math.floor(Date.now() / 1000), // JWT valid only for 10 minutes. `Date.now()` returns the timestamp in milliseconds, this needs it in seconds.
    join_team: true
  })
    .setProtectedHeader({ alg: 'HS256' })
  return token.sign(Buffer.from(settings.JWT_SHARED_SECRET, 'ascii'))
}

router.post('/', validate(createAccountValidator), async (req, res, next) => {
  /* ... Acount creation code ... */
  res.json({
    user: user.serialize(),
    signupJWT: await generateSignupJWT()
  })
})
ts
// Retreive the user's infos and his associated signup JWT
const { user: { id }, signupJWT } = await apiClient.rest.account.create({
  emailAddress,
  password,
  name
})

Creating an identity

Once the token is generated, a new identity can be created as follows:

ts
const accountInfo = await sealdSDKInstance.initiateIdentity({ signupJWT })

The function returns an object of type accountInfo. This object contains information about the newly created account, including an identifier called sealdId. You'll need to save this identifier and associate it with the user on your back-end, and in the front-end.

The following functions can be used to save this identifier in the front-end and back-end model.

js
/* backend/routes/account.js */
router.post('/sealdId', validate(sealdIdValidator), async (req, res, next) => {
  try {
    const { sealdId } = req.body
    const userId = req.session.user.id
    const user = await User.findOne({ where: { id: userId } })
    await user.setSealdId(sealdId)
    global.io.emit('user:created', user.serialize())
    res.json({ sealdId })
  } catch (error) {
    next(error)
  }
})
ts
await currentUser.setSealdId(sealdId)

Once this identifier has been saved, the SDK instance is ready to encrypt and decrypt, and the identity-specific keys are in memory.

Password-protection of the identity

Once the identity is created, it exists only in memory, it must be saved to be used in a later session.

To do this we will use the @seald-io/sdk-plugin-ssks-password module which allows to protect the identity keys with a password.

ts
/* frontend/src/services/seald.ts */
await sealdSDKInstance.ssksPassword.saveIdentity({
  userId,
  password
})

Once this function is executed, the identity is saved and can be retrieved using the equivalent function sealdSDKInstance.ssksPassword.retrieveIdentity from a newly instantiated instance.

For more details on this mechanism, refer to the guide dedicated to password protection.

TIP

For faster execution, as an advanced usage of the SDK, you can customize the password derivation into a rawEncryptionKey and a rawStorageKey, and pass these instead of the password to ssksPassword.saveIdentity. For more details, see the "Customize the password derivation" section of the Identity Guide.

Exposing a createIdentity and retrieveIdentity functions

If we gather all the elements studied in the previous paragraphs, we can expose two functions:

ts
/* frontend/src/services/seald.ts */
export const createIdentity = async ({ userId, password, signupJWT }: { userId: string, password: string, signupJWT: string }): Promise<string> => {
  await instantiateSealdSDK()
  const accountInfo = await sealdSDKInstance.initiateIdentity({ signupJWT })
  await sealdSDKInstance.ssksPassword.saveIdentity({ userId, password })
  return accountInfo.sealdId
}

export const retrieveIdentity = async ({ userId, password }: { userId: string, password: string }): Promise<string> => {
  await instantiateSealdSDK()
  return await sealdSDKInstance.ssksPassword.retrieveIdentity({ userId, password })
}

Integrate with account creation and login

The next step is to call these functions when creating the account in the application and when connecting.

To create it during account creation:

tsx
/* frontend/src/containers/SignUp.tsx */
import { createIdentity } from '../services/seald'

/* ... */
const currentUser = await User.createAccount({ emailAddress, password, name })
// right after the account is created on the back-end, let's create the Seald identity
await createIdentity({ userId: currentUser.id, password }) // TODO: don't use the clearText password both for account creation and identity protection
// Once the seald identity has been created, let's associate the `sealdId` with the user account
await currentUser.setSealdId(sealdId)
/* ... */

To retrieve upon login:

tsx
/* frontend/src/containers/SignIn.tsx */
import { retrieveIdentity } from '../services/seald'

/* ... */
const currentUser = await User.login({ emailAddress, password })
// right after the account is logged in on the back-end, let's retrieve the Seald identity
const accountInfo = await retrieveIdentity({ userId: currentUser.id, password }) // TODO: don't use the clearText password both for account creation and identity protection
/* ... */

Since the identity is only stored in memory in the browser, if you open a new tab, you will have to retype the password. To get around this problem, we do not try to get the logged-in user's profile from the server:

tsx
/* frontend/src/App.tsx */
/* ... */
const currentUser = User.getCurrentUser() // || await User.updateCurrentUser() // TODO : Seald-SDK loads in memory, cannot retrieve it from session, must type password
/* ... */

We will see later how to make this identity persist in the browser.

DANGER

Here we put aside a major security concern: the authentication password should not be used as is to protect the identity. The authentication method must be modified (with a pre-derivation of the password) so that the application server cannot know the password. A dedicated guide is available here.

Encrypting & decrypting messages

To perform message encryption and decryption, you need to:

  • create a shared encryption session between the recipients;
  • encrypt messages before sending them;
  • decrypt messages after reception.

Encryption Session

To create a shared encryption session between users sealdId_1 and sealdId_2 in the roomId_1 chatroom, we proceed as follows:

js
/* frontend/src/components/Chat.jsx */
import { getSealdSDKInstance } from '../services/seald'

const sealdIds = ['sealdId_1', 'sealdId_2']
const metadata = 'roomId_1'
const session = await getSealdSDKInstance().createEncryptionSession({ sealdIds }, { metadata })

This returns an EncryptionSession with a new unique sessionId assigned by the server.

In this project, we put the chat room identifier roomId in metadata.

To encrypt a message with a session, we proceed as follows:

tsx
/* frontend/src/components/Chat.tsx */
const clearText = 'hello world !'
const encryptedMessage = await session.encryptMessage(clearText)

This will give a string of the form:

js
'{"sessionId":"0000000000000000000000","data":"8RwaOppCD3uIJVFv2LoP3XGXpomj0xsMv4qmMVy30Vdqor2w0+DVCXu3j13PEyN2KfJm6SiSrWDRMDziiiOUjQ=="}'

We can retrieve an instance of the session, either with the sessionId, or directly with the encrypted message:

ts
const retrievedSession = await getSealdSDKInstance().retrieveEncryptionSession({
  encryptedMessage,
  /* OR */
  sessionId: '0000000000000000000000'
})

Once the session is retrieved, it can be used to decrypt a message:

ts
const decryptedMessage = await retrievedSession.decryptMessage(encryptedMessage)

You can also add / remove recipients from the session:

javascript
await session.addRecipients({ sealdIds: [user3SealdId] })

await session.revokeRecipients({ sealdIds: [user3SealdId] })

For more details about the sessions, you can consult the dedicated guide.

Integration in a chat room

To integrate these functions in the chat, three elements must be modified:

  • the sending of messages, which must now encrypt for the recipients of the chat;
  • the reception of messages, which must now be decrypted;
  • the management of the members of a chatroom, which must now transpose the members of a chatroom into cryptographic rights.

Encryption upon sending

We have to call the encrypt function each time a message is posted.

In the Chat.jsx file there is a handleSumbitMessage function that we will modify to encrypt:

tsx
/* frontend/src/components/Chat.tsx */
import { useRef, type FC } from 'react'
import type { EncryptionSession } from '@seald-io/sdk/browser'
/* ... */
const Chat: FC<{ roomId: string | undefined }> = ({ roomId: currentRoomId }) => {
  /* ... */

  const sealdSessionRef = useRef<EncryptionSession | null>(null)
  /* ... */

  const handleSubmitMessage = (e: FormEvent<HTMLFormElement>): void => {
    e.preventDefault()
    const asyncHandleSubmitMessage = async (): Promise<void> => {
      if (state.room == null) {
        enqueueSnackbar('Please select a room or create a new one', { variant: 'error' })
      } else if (state.message.trim() !== '') {
        try {
          // If a session is not known for this chat session, let's create one:
          if (sealdSessionRef.current == null) {
            const sealdIds = users.filter(u => state.room?.users.includes(u.id)).map(x => x.sealdId)
            sealdSessionRef.current = await getSealdSDKInstance().createEncryptionSession({ sealdIds }, { metadata: state.room.id })
          }
          // Use this session to encrypt the message
          const encryptedMessage: string = await sealdSessionRef.current.encryptMessage(state.message)

          // Post the `encryptedMessage` instead of the `message`
          await state.room.postMessage(encryptedMessage)

          setState(draft => {
            draft.message = ''
          })
        } catch (error) {
          console.error(error)
          enqueueSnackbar(getMessageFromUnknownError(error), { variant: 'error' })
        }
      }
    }
    void asyncHandleSubmitMessage()
  }
  /* ... */
}

Also, when creating a multi-user chatroom in ManageDialogRoom.tsx a 'Hello 👋' message is sent. It must be encrypted:

tsx
/* frontend/src/components/ManageDialogRoom.tsx */

/* ... */

const newRoom = await Room.create(
  dialogRoom.name,
  dialogRoom.selectedUsers
)
const sealdSession: EncryptionSession = await getSealdSDKInstance().createEncryptionSession({
    sealdIds: users.filter(u => dialogRoom.selectedUsers.includes(u.id)).map(u => u.sealdId)
  },
  { metadata: newRoom.id })
const encryptedMessage: string = await sealdSession.encryptMessage('Hello 👋')
await newRoom.postMessage(encryptedMessage)

/* ... */

Decryption upon receiving

We need to call the decrypt function each time a message is collected.

To do this, we will create a couple of helper functions in Chat.tsx that will be used to use the same data model everywhere:

ts
/* frontend/src/components/Chat.tsx */
const decryptMessage = async (m: MessageType): Promise<DecryptedMessageType> => {
  // If message is a --FILE--, encryptedMessage does not contains an encrypted message
  if (m.encryptedMessage === '--FILE--') return { ...m, message: '--FILE--' }
  try {
    if (sealdSessionRef.current == null) { // no encryption session set in cache yet
      // we try to get it by parsing the current message
      sealdSessionRef.current = await getSealdSDKInstance().retrieveEncryptionSession({ encryptedMessage: m.encryptedMessage })
      // now that we have a session loaded, let's decrypt
    }
    const decryptedMessage = await sealdSessionRef.current.decryptMessage(m.encryptedMessage)
    // we have successfully decrypted the message
    return {
      ...m,
      message: decryptedMessage
    }
  } catch (error) {
    /* handle error */
  }
}

const serializeMessage = (m: MessageTypeAPI): MessageType =>
  (m.uploadId != null
    ? {
        encryptedMessage: m.content,
        uploadId: m.uploadId,
        uploadFileName: m.uploadFileName,
        timestamp: m.createdAt,
        senderId: m.senderId,
        id: m.id
      }
    : {
        encryptedMessage: m.content,
        uploadId: null,
        uploadFileName: null,
        timestamp: m.createdAt,
        senderId: m.senderId,
        id: m.id
      })

Let's modify the eventListener of the event room:messageSent:

tsx
/* frontend/src/components/Chat.tsx */
useEffect(() => {
  const listener: ServerToClientEvents['room:messageSent'] = async (payload) => {
    if (currentRoomId === payload.roomId) {
      const message = serializeMessage(payload)

      const clearMessage = await decryptMessage(message)

      setState(draft => {
        if (draft.messages.find(m => m.id === payload.id) == null) {
          draft.messages = [...draft.messages, clearMessage]
        }
      })
    }
  }
  if (socket != null) socket.on('room:messageSent', listener)
  return () => {
    if (socket != null) socket.off('room:messageSent', listener)
  }
}, [socket, currentRoomId, setState])

We are going to modify the retrieval of the history of the messages at the loading of a room:

tsx
/* frontend/src/components/Chat.tsx */
const init = async (): Promise<void> => {
  const currentRoom = rooms.find(r => r.id === currentRoomId)
  if (users != null && currentRoom != null && currentUser != null) {
    try {
      if (!(currentRoom.users.includes(currentUser.id))) {
        enqueueSnackbar('Access denied', { variant: 'error' })
        setState(draft => {
          draft.isRoomInvalid = true
        })
      } else {
        setState(draft => {
          draft.message = ''
          draft.isCustomRoom = !currentRoom.one2one
          draft.room = currentRoom
          draft.roomTitle = currentRoom.one2one ? users.find(user => currentRoom.users.includes(user.id) && user.id !== currentUser.id)?.name ?? '' : currentRoom.name
          draft.canCustomizeRoom = !currentRoom.one2one && currentRoom.ownerId === currentUser.id
          draft.users = users.filter(u => currentRoom.users.includes(u.id))
          draft.messages = []
        })

        sealdSessionRef.current = null
        const messages = (await currentRoom.getMessages()).map(serializeMessage)

        const clearMessages = await Promise.all(messages.map(decryptMessage))

        setState(draft => {
          draft.messages = [...clearMessages]
          draft.isLoading = false
        })
      }
    } catch (error) {
      console.error(error)
      enqueueSnackbar(getMessageFromUnknownError(error), { variant: 'error' })
      setState(draft => {
        draft.isRoomInvalid = true
      })
    }
  } else if (state.room != null) { // room is already set, but does not exist anymore, must have been deleted re-render arrived here
    setState(draft => {
      draft.isRoomInvalid = true
    })
  } else {
    enqueueSnackbar('This room does not exist', { variant: 'error' })
    setState(draft => {
      draft.isRoomInvalid = true
    })
  }
}

Modifying the rights upon editing a room

When the room is multi-user, the creator of the room can add / remove members. The sessions allow to manage these movements.

To do this, when the creator of the room makes these movements on the room, he must make the same movements on the encryption session.

When a room is modified, the session associated with the room must be given during the dispatch:

tsx
/* frontend/src/components/Chat.tsx */
const handleEditRoom = () => {
  dispatch({
    type: START_EDIT_DIALOG_ROOM,
    payload: {
      room: state.room,
      name: state.room.name,
      selectedUsersId: state.room.users,
      sealdSession: sealdSessionRef.current
    }
  })
}

In ManageDialogRoom.tsx before making the API call to the server to actually edit the room:

tsx
/* frontend/src/components/ManageDialogRoom.tsx  */
if (dialogRoom.room != null) {
  const sealdIdsToRevoke = users // start with all known users
    .filter(u =>
      dialogRoom.room!.users.includes(u.id) && // filter only users which are currently members
      !dialogRoom.selectedUsers.includes(u.id) // but which are not selected anymore
    ).map(u => u.sealdId) // and get their sealdId (not the same as u.id)
  const sealdIdsToAdd = users // start with all known users
    .filter(u =>
      dialogRoom.selectedUsers.includes(u.id) && // filter only users which are selected
      !dialogRoom.room!.users.includes(u.id) // but which are not currently members
    ).map(u => u.sealdId) // and get their sealdId (not the same as u.id)
  await dialogRoom.sealdSession.revokeRecipients({ sealdIds: sealdIdsToRevoke })
  await dialogRoom.sealdSession.addRecipients({ sealdIds: sealdIdsToAdd })
  await dialogRoom.room.edit({ name: dialogRoom.name, users: dialogRoom.selectedUsers })
}

Conclusion

We were able to quickly integrate encryption into this project, but there are still three 3 issues remain before going into production:

  • password pre-derivation;
  • identity persistence when opening a new tab by storing the identity in localstorage.