paint-brush
Comment créer une conversation cryptée de bout en bout avec Seald et PubNubpar@seald
557 lectures
557 lectures

Comment créer une conversation cryptée de bout en bout avec Seald et PubNub

par Seald16m2023/09/20
Read on Terminal Reader

Trop long; Pour lire

Cet article vous guide dans la création d'une discussion cryptée de bout en bout à l'aide de PubNub et du SDK Seald. Il souligne l'importance du chiffrement de bout en bout pour la confidentialité et la sécurité, et explique pourquoi Seald.io est préférable au chiffrement natif de PubNub pour la gestion des clés. L'article couvre la mise en œuvre de salles de discussion individuelles et de groupe, l'authentification des utilisateurs et la gestion des accès en temps réel à l'aide de PubNub. Il décrit également le processus d'intégration de Seald pour un cryptage et un décryptage robustes des messages et des fichiers au sein de l'application de chat. Dans l'ensemble, il fournit un guide complet pour créer une application de chat sécurisée.
featured image - Comment créer une conversation cryptée de bout en bout avec Seald et PubNub
Seald HackerNoon profile picture
0-item
1-item

Bonjour 👋


Aujourd'hui, découvrons comment créer une discussion cryptée de bout en bout avec Pubnub et le SDK seald.


👉 Un exemple pleinement fonctionnel de la façon d'utiliser PubNub et Seald peut être trouvé ici : https://github.com/seald/pubnub-example-project


Qu'est-ce que Pubnub ?

PubNub est un service de communication en temps réel pouvant être intégré à la plupart des applications. Fiable et évolutif, il s’intègre facilement aux frameworks les plus courants.


C'est quoi Seald ?

Seald.io propose un SDK qui permet d'effectuer un chiffrement de bout en bout , avec des fonctionnalités de gestion avancées, sans aucune connaissance préalable en cryptographie. Ce SDK peut être intégré à des applications Web, backend, mobiles ou de bureau.


Aperçu du contenu

  • Pourquoi utiliser le chiffrement de bout en bout ?
  • Pourquoi utiliser Seald.io au lieu du hook de chiffrement PubNub ?👀
  • Objectifs 🏆
  • Mise en œuvre 🧠
  • Ajout du chiffrement de bout en bout avec Seald 🔒💬
  • Conclusion ✅


Pourquoi utiliser le chiffrement de bout en bout ? 🔒

Le cryptage de bout en bout offre le plus haut niveau de confidentialité et de sécurité. Il permet de chiffrer les données sensibles dès leur collecte. Un chiffrement précoce réduira la surface d’attaque de votre application. Un autre avantage est que vous pouvez gérer précisément qui peut accéder aux données. Cela protégera également lorsque cela n’est pas dans votre champ d’application, comme les services tiers.


Le chiffrement de bout en bout vous permet de garder le contrôle à tout moment, lorsque vos données sont en transit, lorsqu'elles sont au repos et même lorsqu'elles ne sont pas entre vos mains. Ainsi, il offre une protection bien plus large que les autres technologies de chiffrement (TLS, chiffrement complet du disque, ...).


Chaque fois que vous êtes confronté à un cas où la conformité est importante (RGPD, HIPAA, SOC-2,...) ou où vous disposez de données sensibles (médicales, défense,...), le chiffrement de bout en bout est indispensable. Mais même pour des données plus courantes, c’est une bonne pratique. Une violation de données est un événement dévastateur de plus en plus fréquent.


Pourquoi utiliser Seald.io au lieu du hook de chiffrement PubNub ?👀

Le SDK PubNub propose un crochet de chiffrement simple, utilisant l'argument cipherKey lors de l'instanciation de son SDK. Cela garantira que tous les messages téléchargés sont cryptés avant d'être envoyés. Cependant, vous devez effectuer vous-même la gestion des clés, ce qui constitue la partie la plus difficile d’un système chiffré de bout en bout.


Les vulnérabilités proviennent rarement du cryptage lui-même, mais le plus souvent de la fuite de clés via des failles dans le modèle de sécurité.


Utiliser un service tiers pour votre sécurité vous permet de ne pas avoir un seul point de défaillance. Seald.io propose un modèle de sécurité robuste, certifié par l'ANSSI, avec contrôle de gestion des accès en temps réel, révocation et récupération des utilisateurs, 2FA, etc.


Objectifs 🏆

Cet article explique comment intégrer Seald à PubNub étape par étape, afin de sécuriser votre discussion avec un cryptage de bout en bout. Nous allons créer un exemple d'application de messagerie, avec les fonctionnalités suivantes :


  • Salons de discussion individuels et en groupe.

  • Chaque membre dispose d'une salle de discussion dédiée avec tous les autres utilisateurs.

  • N'importe qui peut créer une salle de discussion de groupe avec plusieurs autres utilisateurs.

  • Utilisez le cryptage de bout en bout pour chaque message et fichier envoyé.

  • Autorisez la gestion des accès en temps réel aux chats.


Mise en œuvre 🧠

Créez un compte PubNub 👤

Pour commencer, vous aurez besoin d'un compte PubNub. Vous pouvez vous inscrire ici . Une fois connecté sur votre tableau de bord, vous devriez voir qu'une application de démonstration avec un jeu de clés de démonstration a été créée :


Sélectionnez le jeu de clés de démonstration et faites défiler jusqu'à l'onglet de configuration. Pour notre démo, nous devons activer les autorisations Files et Objects . Pour l'autorisation Object , nous utiliserons les événements suivants : User Metadata Events , Channel Metadata Events et Membership Events .


Une fois le jeu de clés créé et configuré, nous devons le copier sur notre frontend.

Créons un fichier JSON sur le dossier src/ , appelé settings.json . Nous utiliserons ce fichier pour toutes les clés API dont nous aurons besoin. En commençant par le jeu de clés PubNub :

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


Construire une discussion de base à l'aide de PubNub 💬

Nous utiliserons PubNub pour presque toutes les tâches backend. Notre backend gérera uniquement l'inscription/la connexion des utilisateurs et utilisera un modèle d'utilisateur minimaliste avec uniquement un identifiant, un nom et un e-mail.


Sur la face avant, nous avons besoin d'une petite interface d'authentification :


Une fois que l'utilisateur dispose d'un compte, la première chose dont il a besoin est une instance du SDK PubNub.

Pour identifier l'utilisateur sur Pubnub, nous devons fournir un UUID.


Pour faire simple, nous utiliserons le même identifiant que sur notre 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 })



Pour garder notre backend aussi simple que possible, nous utiliserons les métadonnées utilisateur de PubNub pour échanger les informations des utilisateurs.


Juste après l'instanciation du SDK, nous appelons simplement la fonction PubNub setUUIDMetadata :

 /* frontend/src/App.js */ await pubnub.objects.setUUIDMetadata({ uuid: currentUser.id, data: { email: currentUser.emailAddress, name: currentUser.name } })


Obtenir l'état initial de l'application 🌱

La première chose à faire avec PubNub est de récupérer tous les membres existants et de les stocker dans notre magasin de données local :

 /* 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 })) } })


Chaque salon de discussion correspondra à un canal PubNub. Nous ajouterons également quelques métadonnées à chaque canal :


  • ownerId : L'ID de l'utilisateur qui a créé la salle.

  • one2one : Un booléen pour différencier les salles de messagerie directe et les salles de groupe.

  • archived : Un booléen, pour cacher un salon de groupe supprimé.


Les métadonnées ownerId seront utilisées ultérieurement lors de l'ajout du SDK Seald. PubNub n'a pas de concept de propriété, contrairement à Seald. Il définira qui peut ajouter ou supprimer des utilisateurs d'une chaîne. Il définit essentiellement un administrateur de groupe.


Nous commencerons par récupérer les salons de discussion existants. Nous aurons également besoin des métadonnées de la salle, nous devons donc inclure des champs personnalisés. Ensuite, nous devons filtrer les salles archivées et tout envoyer à notre magasin de données.


Enfin, on subscribe à la chaîne PubNub associée à la salle, nous recevrons donc de nouveaux 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) })


Nous avons maintenant récupéré toutes les salles dans lesquelles nous nous trouvons. Nous avons besoin d'une dernière chose pour terminer l'initialisation de l'application : nous assurer que nous avons une salle one2one avec tous les autres membres, y compris les nouveaux enregistrés.


Pour chaque nouvel utilisateur trouvé, nous créerons une nouvelle salle et enverrons un message de bonjour.


Ensuite, nous définirons les métadonnées de la salle et nous y abonnerons :

 /* 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] }) } }


Une fois tout cela fait, l’état initial de notre application est entièrement défini. Cependant, nous devons le maintenir à jour.


Cela peut être fait simplement en ajoutant un écouteur d'événement pour les événements membership :

 /* 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


Nous pouvons maintenant jeter un œil à un salon de discussion one2one lui-même. Nous nous occuperons ensuite des salles de groupe.


Recevoir et envoyer des messages dans un salon de discussion 📩

Dans un fichier chat.js , nous aurons toute la logique pour afficher les messages d'un chat room.

Pour initialiser cette salle, la seule chose à faire est de récupérer tous les messages préexistants.


Cela peut être fait simplement en connaissant l’ID de la pièce :

 /* frontend/src/components/Chat.jsx */ const fetchedMessages = (await pubnub.fetchMessages({ channels: [currentRoomId] })).channels[currentRoomId]


Nous pouvons nous abonner à la chaîne pour recevoir de nouveaux messages, et ajouter un auditeur pour les afficher en temps réel :

 /* frontend/src/components/Chat.jsx */ pubnub.addListener({ message: handleReceiveMessage }) pubnub.subscribe({ channels: [currentRoomId] })


Pour envoyer un message, il suffit de le publier sur la chaîne :

 /* 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 } }) }


Pour envoyer un fichier, nous allons d'abord le télécharger sur PubNub. Ensuite, nous obtiendrons l'URI du fichier téléchargé et le publierons sous forme de message dans le salon de discussion :

 /* 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) } })


Gestion du chat de groupe 👨‍👩‍👦‍👦

Pour créer et gérer des groupes, nous aurons besoin d'une interface de sélection des utilisateurs :



Une fois les membres du groupe sélectionnés, nous pouvons créer une chaîne PubNub pour notre salle, puis définir les métadonnées et l'adhésion à la chaîne. Le code est très similaire à ce qui se fait pour les salles one2one, nous ne le répéterons donc pas ici.

Nous avons maintenant une application de chat complète. Ajoutons un cryptage de bout en bout pour chaque message !


Ajout du chiffrement de bout en bout avec Seald 🔒💬

Créez un compte Seald 👤


Pour commencer avec Seald, créez un compte d'essai gratuit ici


Lors de l'atterrissage sur le tableau de bord Seald, quelques URL et jetons API sont affichés.


Obtenez les éléments suivants :

  • identifiant d'application
  • apiURL
  • cléStorageURL


Nous ajouterons ces clés à notre 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" }


Pour pouvoir utiliser le SDK Seald, chaque utilisateur a besoin d'une licence JWT lors de son inscription.

Ces JWT doivent être générés sur le backend à l'aide d'un secret et d'un ID secret.

Depuis la page de destination du tableau de bord, copiez le secret JWT et son ID associé dans backend/settings.json :

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


Lors de l'appel d'API d'inscription, nous générerons une licence JWT Seald et la renverrons :

 /* 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'))


Pour plus d'informations à ce sujet, voir notre article de documentation sur JWT .


Ensuite, nous devons installer le SDK Seald.


Nous devons également installer un plugin pour identifier les utilisateurs sur le serveur Seald. Pour ce faire, nous utiliserons le package sdk-plugin-ssks-password .


Ce plugin permet une authentification simple par mot de passe de nos utilisateurs :

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

Instanciation du SDK Seald 💡

Ensuite, nous créerons un fichier seald.js . Dans ce fichier, nous allons commencer par créer une fonction pour instancier le SDK Seald :

 /* 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)] }) }


Création et récupération des identités Seald 🔑

Dans seald.js , nous ajouterons également deux fonctions : une pour créer une identité et une pour en récupérer une. Pour créer une identité, nous avons également besoin de la licence JWT renvoyée lors de la création du compte :

 /* 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 }) }


Lors de nos flux d'inscription et de connexion, il nous suffit d'appeler ces fonctions une fois que l'utilisateur s'est connecté.


À ce stade, chaque fois que notre utilisateur est connecté, il dispose d'un SDK Seald fonctionnel, prêt à crypter et déchiffrer !


Attention : Pour une bonne sécurité, le mot de passe doit être pré-haché avant d'être envoyé au serveur d'authentification. Pour plus de détails à ce sujet, veuillez consulter le paragraphe sur l'authentification par mot de passe dans notre documentation .

Commencez à chiffrer et décrypter les messages 🔒🔓

Chaque salle de discussion sera associée à une encryptionSession sur le SDK Seald.

Chaque fois que nous créons un salon de discussion, il suffit d'ajouter une ligne pour créer la session de cryptage, puis de l'utiliser :

 /* 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 👋')) } })


Notez que notre utilisateur est inclus par défaut en tant que destinataire de la encryptionSession.


Lors de l'accès à une salle, nous devons obtenir le encryptionSession correspondant. Il peut être récupéré à partir de n’importe quel message ou fichier crypté. Une fois que nous l'aurons, nous le conserverons dans la référence des composants.


Ensuite, nous pouvons simplement utiliser les fonctions session.encryptMessage , session.encryptFile , session.decryptMessage et session.decryptFile .


Commençons par les messages. Pour envoyer un 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... */ }


Et quand on reçoit un 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] }) }


Aussi, nous utilisons cette fonction decryptMessage pour décrypter tous les messages déjà dans la session lors de l'ouverture d'une salle :

 /* frontend/src/components/Chat.js */ const fetchedMessages = (await pubnub.fetchMessages({ channels: [currentRoomId] })).channels[currentRoomId] const clearMessages = fetchedMessages ? await Promise.all(fetchedMessages.map(decryptMessage)) : []


Et maintenant pour les fichiers. Pour télécharger un fichier :

 /* 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 })


Et pour décrypter un fichier :

 /* 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 */ } }


Gestion des membres du groupe 👨‍👩‍👦

Les discussions de groupe auront également leur encryptionSession . Chaque fois qu'un groupe est créé, nous devons en créer un :

 /* frontend/src/components/ManageDialogRoom.js.js */ // To create the encryptionSession const sealdSession = await getSealdSDKInstance().createEncryptionSession( { userIds: dialogRoom.selectedUsersId }, { metadata: newRoomId } )


Ensuite, chaque fois que nous modifierons des membres du groupe, nous devrons les ajouter ou les supprimer :

 /* 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 ✅

Une fois cela fait, nous avons terminé !


Nous avons réussi à intégrer Seald dans PubNub avec seulement quelques lignes de code.


Maintenant que le chat est crypté de bout en bout, vous pouvez assurer à vos utilisateurs que leurs données resteront confidentielles, même en cas de violation de données.


Comme toujours, n'hésitez pas à Contactez-nous si vous avez besoin de conseils supplémentaires.


J'ai hâte de voir ce que vous avez construit 🥳.




Également publié ici.