Créer une messagerie chiffrée de bout-en-bout avec Stream et Seald 🔒💬
L'article original a été écrit par Sander Goossens, développeur chez In The Pocket, vous pouvez le trouver ici : https://dev.inthepocket.com/posts/2022-02-15-creating-an-encrypted-chat-with-getstream-and-seald-io/
Sander Goossens : Récemment, un client est venu nous voir pour nous demander une solution de messagerie avec une confidentialité absolue sur les messages. Après quelques recherches, nous avons rapidement trouvé une solution combinant GetStream.io et Seald.io.
GetStream.io offre une messagerie en temps réel avec une infrastructure fiable et des SDK riches en fonctionnalités. L'un des principaux arguments de vente de GetStream.io est la disponibilité de composants d'UI prêts à l'emploi. Avec la prise en charge de React Native, React, Android, Flutter, iOS/Swift,... vous pouvez rapidement créer une application de messagerie sans avoir à créer vous-même chaque composant d'UI.
Seald.io propose un SDK de chiffrement de bout-en-bout, très simple à utiliser.
Le chiffrement de bout-en-bout est la technologie la plus sécurisée qui soit, mais c'est aussi la plus complexe et la plus longue à mettre en œuvre (gestion des clés, récupération des comptes utilisateurs, etc.).
Pour simplifier son adoption, Seald a développé un SDK associé à une API, qui vous permet d'ajouter le chiffrement de bout-en-bout dans vos applications en quelques lignes de code, sans aucune connaissance préalable en cryptographie.
La solution bénéficie d'un visa de sécurité (CSPN) délivré par l'ANSSI, qui vous assure un modèle de sécurité robuste.
Grâce aux API et aux composants SDK mis à disposition par Stream, nous pouvons y intégrer le chiffrement de bout-en-bout de Seald.
Assez d'introduction, voyons ce que nous allons construire et comment nous l'avons abordé !
Le but
- Les messages ne doivent être lisibles que par les membres d'une conversation.
- Les messages ne doivent pas être lisibles dans le tableau de bord de l'administrateur du flux.
Services
- API de messagerie : backend nodeJS personnalisé pour gérer toutes les fonctionnalités GetStream et Seald côté serveur. Avoir une base de données postgreSQL où nous stockons les utilisateurs de Seald.
- API des utilisateurs : backend nodeJS personnalisé pour gérer tout ce qui concerne les utilisateurs.
La mise en œuvre
Commençons notre application par un écran d'inscription où l'utilisateur peut saisir ses coordonnées. Lorsqu'un utilisateur s'inscrit avec succès, nous pouvons l'authentifier avec notre fournisseur d'authentification.
Le fournisseur d'authentification renvoie un code JWT qui peut être utilisé dans l'ensemble de l'application.
{ "accessToken": "eyJ2ZXIiOiIxLjAiLCJraWQiOiIzYWMxMTjYwZi0yMzhm..."}
Nous souhaitons connecter l'utilisateur aux serveurs GetStream, et nous devons donc récupérer un jeton JWT auprès de GetStream. Un point de terminaison de notre API de messagerie s'en chargera. Utilisez l'ID utilisateur de votre fournisseur d'authentification pour créer un jeton auprès de GetStream.
import { StreamChat } from 'stream-chat';
const serverClient = StreamChat.getInstance(STREAM_KEY, STREAM_SECRET);
export async function getMessagingToken(id: string) {
return serverClient.createToken(id);
}
Nous allons stocker ce jeton de messagerie dans notre application react-native. Nous utilisons pour cela Zustand ❤️. En utilisant ce jeton, nous sommes maintenant en mesure de connecter un utilisateur à notre client GetStream.
Passons à l'étape suivante de notre processus d'intégration. Nous devons maintenant initialiser notre 2FA avec Seald. Nous avons créé un écran où l'utilisateur peut entrer son numéro de téléphone.
Lorsqu'un utilisateur saisit son numéro de téléphone, nous devons envoyer un challenge que l'utilisateur peut saisir sur l'écran suivant. Nous avons créé un hook personnalisé useTwoFactorAuth
pour gérer la configuration de 2FA.
Pour effectuer les appels à l'API, nous utilisons react-query. Je ne vais pas me plonger dans chaque hook personnalisé, les 3 hooks ci-dessous récupèrent des données avec react-query à partir de nos points d'API.
const { signup, isLoading } = useSignupSeald();
const { fetchMe, isLoading: isLoadingFetchMe } = useMeSeald();
const { sendChallenge, isLoading: isLoadingSendChallenge } = useSendChallenge();
Lorsqu'un utilisateur n'existe pas encore, nous appelons le point d'API signup
. Ce point d'API s'assurera que l'utilisateur est sauvegardé dans notre base de données postgreSQL et qu'une clé TwoManRuleKey
est créée et stockée dans notre base de données. Pour en savoir plus sur la protection en 2-man-rule, vous pouvez consulter la documentation.
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();
}
}
};
Pour que Seald puisse fonctionner correctement, nous devons générer un jeton de licence utilisateur dans notre backend. Voir la documentation pour plus d'informations sur la manière de procéder. Chaque fois que le point d'API permettant de récupérer l'utilisateur ou de l'inscrire est appelé, un jeton de licence est généré et renvoyé par notre API.
Lorsqu'un utilisateur existant se reconnecte, le point d'API fetchMe
renvoie la réponse suivante :
{
"id":"0015r00000OxGgIAAV",
"isEnrolled":true,
"sealdId":"TESTING_0015r00000OxGgIAAV",
"twoManRuleKey":"VCl2Z0U+LYZ6Fq2rjm40PFrYWlrLDIsSRgGufKo9wJGlxDAU+5mUwji21g2G86GzN3dLKsrdWmYbPZn0QTGa9g=="
}
Lorsqu'un nouvel utilisateur est créé lors de l'inscription, la réponse du point d'API signup
est la suivante :
{
"activationKey":"dbbb72c5-b2c5-47db-ad4c-5dfec91918df:5b1f82d5f3cb073c0103a4aa55bdd900777a4b3b4b89b99d906af533f3358a6e:7f24176eff547227984710ac5b2d858b3a78af509225f5b6cdc41197254dd81a02b16d893b176ffd70c952b87c4ba95a1f98b9f0bc3d0926f117e832fa45a9f3",
"id":"0015r00000Q0DQcAAN",
"isEnrolled":false,
"sealdId":"TESTING_0015r00000Q0DQcAAN",
"twoManRuleKey":"miSTd8ad7FrtvNSMee+uLDLgj+tYCPf9iENm3CzsytILSw5YuMI1TEOtEF7sU46f4qa6KVF+wb3tU1cdbLzTBA=="
}
Maintenant que notre utilisateur est créé, nous pouvons envoyer le challenge à l'utilisateur et initier l'identité Seald avec la clé d'activation.
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);
}
}
La commande sendChallenge
appellera le point d'API qui met en œuvre l'envoi d'un challenge conformément aux spécifications. Seald créera une "session" et cette session sera requise lors de la sauvegarde ou de la récupération de notre identité Seald.
{
"mustAuthenticate": true,
"twoManRuleKey":"miSTd8ad7FrtvNSMee+uLDLgj+tYCPf9iENm3CzsytILSw5YuMI1TEOtEF7sU46f4qa6KVF+wb3tU1cdbLzTBA==",
"twoManRuleSessionId":"c1c2b55f-f119-4a88-8a0d-e95512e3c667"
}
Nous pouvons maintenant passer au dernier écran de saisie du challenge.
Un hook personnalisé sera appelé lorsque le challenge a été saisi :
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();
}
}
},
};
};
Nous avons maintenant établi une identité sécurisée et pouvons utiliser cette identité pour créer des sessions sécurisées entre deux utilisateurs 🙌
Créer une messagerie instantanée sécurisée
GetStream.io nous offre des composants d'UI qui s'intègrent facilement dans notre application react-native. Nous avons modifié certains composants de GetStream, mais dans cet exemple, je ne montrerai que les choses nécessaires.
Lorsque vous créez un écran pour le chat, vous pouvez commencer par une implémentation de base comme celle-ci :
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>
);
Sans chiffrer/déchiffrer les messages, la messagerie se présente comme suit :
Mais nous voulons pouvoir envoyer des messages chiffrés de bout-en-bout. Nous devons donc changer la méthode doSendMessageRequest
. Comme vous pouvez le voir, nous chiffrons notre message à l'aide d'une session de chiffrement. Cette session de chiffrement doit être créée lors de la création de votre conversation.
Lorsqu'une session de chiffrement est créée entre deux utilisateurs, nous stockons l'ID de la session dans les métadonnées de la conversation dans GetStream, afin qu'il puisse être réutilisé pour récupérer une session de chiffrement.
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>
);
Si nous envoyons maintenant un autre message, vous pouvez voir que le texte du message est maintenant chiffré et illisible.
Nous devons changer le composant MessageText
pour afficher le message.
return (
<Chat
client={client}
>
<Channel
channel={channel}
doSendMessageRequest={sendMessage}
MessageText={(props: MessageTextProps) => (
<DecryptedMessageText
{...props}
channel={channel}
session={encryptionSession}
onError={handleDecryptionError}
onFinished={handleDecryptionEnd}
/>
)}
/>
</Chat>
);
Notre composant DecryptedMessageText
utilisera notre session de chiffrement pour déchiffrer le message.
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;
Notre message est maintenant déchiffré et de nouveau visible pour les membres de la conversation.
Lorsque nous jetons un coup d'œil au tableau de bord de GetStream.io, nous pouvons également voir que nos messages ne sont pas lisibles, et qu'ils sont donc chiffrés de bout-en-bout 💪.
Conclusion
Ps : vous utilisez PubNub plutôt que Stream ? Voici un article qui explique commente intégrer Seald dans PubNub.