Démarrage rapide
Pour démarrer rapidement, nous allons intégrer les fonctionnalités de chiffrement & déchiffrement directement dans le projet exemple, en protégeant l'identité par mot de passe.
La branche sur laquelle est basée cette étape est master
, le résultat final est 1-quick-start
.
Installation
Dans le front-end, nous allons utiliser la version "web" du SDK @seald-io/sdk
avec le module de protection de l'identité par mot de passe @seald-io/sdk-plugin-ssks-password
:
cd frontend
npm install --save @seald-io/sdk @seald-io/sdk-plugin-ssks-password
Sur la partie back-end, nous allons avoir besoin de générer des JSON Web Tokens. Pour ce faire, nous installons le module jose
:
cd ../backend
npm install --save jose
Configuration
Pour configurer le SDK, il faut se connecter à votre tableau d'administration et récupérer les 5 éléments suivants :
APP_ID
: UUID unique pour votre application ;JWT_SHARED_SECRET
: secret de JWT ;JWT_SHARED_SECRET_ID
: UUID associé au secret correspondant àJWT_SHARED_SECRET
utilisée pour générer des JWT.API_URL
: URL du serveur d'API à utiliser ;KEY_STORAGE_URL
: URL du serveur de stockage des identités à utiliser.
TIP
Les secrets de JWT peuvent être généré dans les paramètres, onglet secret de JWT
.
Pour plus d'informations, référez-vous à cette documentation.
TIP
Pour créer un compte développeur, suivez les premiers pas.
Pour instancier le SDK, on procède comme suit :
/* frontend/src/services/seald.ts */
import SealdSDKConstructor, { type SealdSDK } from '@seald-io/sdk/browser'
import SealdSDKPluginSSKSPassword from '@seald-io/sdk-plugin-ssks-password/browser'
/* ... */
let sealdSDKInstance: SealdSDK
const instantiateSealdSDK = async (): Promise<void> => {
sealdSDKInstance = SealdSDKConstructor({
appId: await getSetting('APP_ID'),
apiURL: await getSetting('API_URL'),
plugins: [SealdSDKPluginSSKSPassword(await getSetting('KEY_STORAGE_URL'))]
})
}
export const getSealdSDKInstance = (): SealdSDK => sealdSDKInstance
TIP
Étant donné que l'instance du SDK est partagée à travers toute une application en React, il est préférable de la stocker dans une variable dans un fichier de service seald.js
et d'exposer un getter plutôt que de l'enregistrer dans une variable immutable.
Gestion des identités Seald
Lors de la création d'une identité Seald, il faut l'associer au compte développeur, et d'autre part à l'utilisateur dans l'application dans laquelle on intègre le SDK.
Pour cela, il faut générer un JSON Web Token appelé signupJWT
à partir du JWT_SECRET
et du JWT_SECRET_ID
. Ce signupJWT
sera consommé au initiateIdentity
, qui retournera un sealdId
unique pour l'utilisateur qu'il faudra enregistrer sur le backend.
Génération d'un signupJWT
La génération d'un signupJWT
se fait par votre backend, et est décrite dans ce guide dédié.
Vous trouverez des exemples de création de signupJWT
dans les languages de backend les plus courants dans cette section
Votre backend doit générer un signupJWT
lors de l'appel API de création de compte, puis retourner le token dans la réponse de l'appel.
WARNING
Il est impératif de ne pas révéler publiquement le JWT_SHARED_SECRET
.
En effet, il authentifie les actions du serveur applicatif auprès du serveur Seald. S'il était rendu public, ces actions pourraient être effectuées librement par un attaquant.
Par exemple, un attaquant pourrait créer de nouveaux utilisateurs Seald qui seraient comptabilisés en facturation et réassigner les connecteurs de vos utilisateurs à des comptes Seald qu'ils contrôlent, ce qui pourrait engendrer une fuite de données.
Vous pouvez vous référer au guide dédié aux JWT.
/* 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: random(), // Pour que le JWT ne soit utilisable qu'une seule fois.
iat: Math.floor(Date.now() / 1000), // Validité limitée à 10 minutes. `Date.now()` donne la date en millisecondes, il faut la date en secondes.
join_team: true
})
.setProtectedHeader({ alg: 'HS256' })
return token.sign(Buffer.from(settings.JWT_SHARED_SECRET, 'ascii'))
}
router.post('/', validate(createAccountValidator), async (req, res, next) => {
/* ... Code de création de compte ... */
res.json({
user: user.serialize(),
signupJWT: await generateSignupJWT()
})
})
// Les informations sont récuperées lors de l'appel API de création de compte.
const { user: { id }, signupJWT } = await apiClient.rest.account.create({
emailAddress,
password,
name
})
Création d'une identité
Une fois le jeton généré, on peut créer une nouvelle identité de la façon suivante :
const accountInfo = await sealdSDKInstance.initiateIdentity({ signupJWT })
La fonction retourne un object de type accountInfo
. Cet objet contient des informations sur le compte nouvellement créé, notamment un identifiant appelé sealdId
. Vous aurez besoin de sauvegarder cet identifiant et de l'associer à l'utilisateur sur votre back-end, et dans le front-end.
Les fonctions suivantes permettent de sauvegarder cet identifiant dans le model front-end et back-end.
/* 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)
}
})
await currentUser.setSealdId(sealdId)
Une fois cet identifiant enregistré, l'instance du SDK est prête pour chiffrer et déchiffrer, et les clés propres à l'identité sont en mémoire.
Protection de l'identité par mot de passe
Une fois l'identité créée, elle n'existe qu'en mémoire, il faut la sauvegarder pour pouvoir l'utiliser dans une session ultérieure.
Pour cela nous allons utiliser le module @seald-io/sdk-plugin-ssks-password
qui permet de protéger les clés d'identité par un mot de passe password
.
/* frontend/src/services/seald.ts */
await sealdSDKInstance.ssksPassword.saveIdentity({
userId,
password
})
Une fois cette fonction exécutée, l'identité est sauvegardée et pourra être récupérée en utilisant la fonction équivalente sealdSDKInstance.ssksPassword.retrieveIdentity
depuis une instance nouvellement instanciée.
Pour plus de détails sur ce mécanisme, référez-vous au guide dédié à la protection des identités par mot de passe.
TIP
Pour une exécution plus rapide, dans le cadre d'une utilisation avancée du SDK, vous pouvez personnaliser la dérivation du mot de passe en une rawEncryptionKey
et une rawStorageKey
, et passer celles-ci à la place du mot de passe à ssksPassword.saveIdentity
. Pour plus de détails, allez voir le paragraphe "Personnaliser la dérivation de mot de passe" du guide sur les identités.
Exposer des fonctions createIdentity
et retrieveIdentity
Si on rassemble tous les éléments étudiés dans les parties précédentes, on peut exposer deux fonctions :
/* 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 })
}
Intégrer à la création de compte et à la connexion
L'étape suivante consiste à appeler ces fonctions lors de la création du compte dans l'application et lors de la connexion.
Pour la créer lors de la création de compte :
/* frontend/src/containers/SignUp.tsx */
import { createIdentity } from '../services/seald'
/* ... */
const currentUser = await User.createAccount({ emailAddress, password, name })
// Après la création du compte sur le back-end, créons l'identité Seald
const sealdId = await createIdentity({ userId: currentUser.id, password, signupJWT: currentUser.signupJWT }) // TODO: Ne pas utiliser le mot de passe en clair à la fois pour la création de compte et la protection de l'identité
// Une fois l'identité seald créée, associons le `sealdId` avec le compte utilisateur
await currentUser.setSealdId(sealdId)
/* ... */
Pour la récupérer à la connexion :
/* 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 { sealdId } = await await retrieveIdentity({ userId: currentUser.id, password }) // TODO: Ne pas utiliser le mot de passe en clair à la fois pour la création de compte et la protection de l'identité
/* ... */
Comme l'identité n'est conservée qu'en mémoire dans le navigateur, si vous ouvrez un nouvel onglet, vous devrez retaper le mot de passe. Pour contourner ce problème, nous n'essayons pas d'obtenir du serveur le profil de l'utilisateur connecté :
/* frontend/src/App.tsx */
/* ... */
const currentUser = User.getCurrentUser() // || await User.updateCurrentUser() // TODO : Seald-SDK est chargé en mémoire, il n'est pas possible de le récuperer la session, il faut taper le mot de passe.
/* ... */
Nous verrons plus tard comment faire persister cette identité dans le navigateur.
DANGER
Nous mettons ici de côté un souci majeur de sécurité : le mot de passe d'authentification ne doit pas être utilisé tel quel pour protéger l'identité. Il faut modifier la méthode d'authentification (avec une pré-dérivation du mot de passe) pour que le serveur applicatif ne puisse pas avoir connaissance du mot de passe. Un guide dédié est disponible ici.
Chiffrer & déchiffrer des messages
Pour effectuer des chiffrements et déchiffrements de messages, il faut :
- créer une session de chiffrement partagée entre les destinataires ;
- chiffrer les messages avant envoi ;
- déchiffrer les messages après réception.
Session de chiffrement
Pour créer une session de chiffrement partagée entre les utilisateurs sealdId_1
et sealdId_2
dans le salon de chat roomId_1
, on procède de la façon suivante :
/* frontend/src/components/Chat.tsx */
import { getSealdSDKInstance } from '../services/seald'
const sealdIds = ['sealdId_1', 'sealdId_2']
const metadata = 'roomId_1'
const session = await getSealdSDKInstance().createEncryptionSession({ sealdIds }, { metadata })
Cela renvoie une EncryptionSession avec un nouveau sessionId
unique attribué par le serveur.
Dans le cadre de ce projet, on place dans metadata
l'identifiant du salon de chat roomId
.
Pour chiffrer un message avec une session, on procède comme suit :
/* frontend/src/components/Chat.tsx */
const clearText = 'hello world !'
const encryptedMessage = await session.encryptMessage(clearText)
Cela va donner un string de la forme :
'{"sessionId":"0000000000000000000000","data":"8RwaOppCD3uIJVFv2LoP3XGXpomj0xsMv4qmMVy30Vdqor2w0+DVCXu3j13PEyN2KfJm6SiSrWDRMDziiiOUjQ=="}'
On peut récupérer une instance de la session, soit avec le sessionId
, soit directement avec le message chiffré :
const retrievedSession = await getSealdSDKInstance().retrieveEncryptionSession({
encryptedMessage,
/* OR */
sessionId: '0000000000000000000000'
})
Une fois la session récupérée, on peut l'utiliser pour déchiffrer un message :
const decryptedMessage = await retrievedSession.decryptMessage(encryptedMessage)
On peut aussi ajouter / révoquer des destinataires de la session :
await session.addRecipients({ sealdIds: [user3SealdId] })
await session.revokeRecipients({ sealdIds: [user3SealdId] })
Pour plus de détails sur les sessions, vous pouvez consulter le guide dédié.
Intégration dans un salon de chat
Pour intégrer ces fonctions dans le chat, il faut modifier trois éléments :
- l'envoi des messages, qui doit désormais chiffrer pour les destinataires du salon ;
- la réception de messages, qui doit désormais déchiffrer ;
- la gestion des membres d'un salon, qui doit désormais transposer les membres d'un salon en droits cryptographiques.
Chiffrement à l'envoi
Il faut appeler la fonction encrypt
à chaque fois qu'un message est posté.
Dans le fichier Chat.tsx
se trouve une fonction handleSumbitMessage
que l'on va modifier pour y incorporer du chiffrement :
/* 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()
}
/* ... */
}
Par ailleurs, lors de la création d'un salon de chat multi-utilisateurs dans ManageDialogRoom.tsx
un message 'Hello 👋'
est envoyé. Il faut le chiffrer :
/* 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)
/* ... */
Déchiffrement à la réception
Il faut appeler la fonction decrypt
à chaque fois qu'un message est collecté.
Pour cela, on va créer quelques fonctions helpers dans Chat.tsx
qui serviront à utiliser le même modèle de données partout :
/* 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
})
On va modifier l'eventListener
de l'évènement room:messageSent
:
/* 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])
On va modifier la récupération de l'historique des messages au chargement d'un salon :
/* 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
})
}
}
Modifier les droits à l'édition d'un salon
Lorsque le salon est multi-utilisateurs, le créateur du salon peut ajouter / supprimer des membres. Les sessions permettent de gérer ces mouvements.
Pour cela il faut au moment où le créateur du salon effectue ces mouvements sur le salon, effectuer les mêmes mouvements sur la session de chiffrement.
Lorsque l'on déclenche la modification d'un salon, il faut donner la session associée au salon lors du dispatch
:
/* frontend/src/components/Chat.tsx */
const handleEditRoom = (): void => {
dispatch({
type: START_EDIT_DIALOG_ROOM,
payload: {
room: state.room,
name: state.room.name,
selectedUsersId: state.room.users,
sealdSession: sealdSessionRef.current
}
})
}
Dans ManageDialogRoom.tsx
avant de faire l'appel API au serveur pour effectivement éditer le salon :
/* 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
Nous avons pu intégrer rapidement du chiffrement dans ce projet, restent en suspens 3 sujets avant de passer en production :
- la pré-dérivation du mot de passe ;
- la persistance de l'identité à l'ouverture d'un nouvel onglet en stockant l'identité en localstorage .