Skip to content

Base de données persistante en localStorage

Lors de l'étape précédente, nous avions ajouté une étape de pré-dérivation du mot de passe sur la méthode d'authentification pour assurer la sécurité de la protection par mot de passe de l'identité Seald d'un utilisateur.

Nous sommes toujours confrontés au problème que quand un utilisateur ouvre un nouvel onglet de l'application, bien qu'ayant une session authentifiée, l'utilisateur doit retaper son mot de passe pour récupérer son identité Seald.

Lors de cette étape, nous ajouterons une mise en cache de l'identité en localStorage pour que l'utilisateur puisse ouvrir un nouvel onglet.

La branche sur laquelle est basée cette étape est 2-pre-derivation , le résultat final est 3-localstorage .

Explication

Dans le mécanisme implémenté dans les étapes précédentes, l'identité Seald d'un utilisateur est récupérée depuis SSKS puis déchiffrée à l'aide du mot de passe de l'utilisateur. Ainsi, cette identité n'est conservée qu'en mémoire côté client.

Il est pourtant souhaitable que quand un utilisateur actualise la page, ou ouvre un nouvel onglet, il ait accès à son identité Seald sans retaper de mot de passe.

La solution que nous préconisons est de mettre la base de donnée en cache dans le navigateur dans le localStorage, et par mesure de précaution, nous préconisons de protéger cette base de données en localStorage par une clé stockée en backend appelée databaseKey.

TIP

La base de donnée de Seald utilise nedb, qui dans le navigateur, n'utilise pas forcément le localStorage, mais choisit (via localForage) la meilleure méthode de stockage entre IndexedDB, WebSQL ou localStorage selon votre navigateur.

uml diagram

Lorsque l'utilisateur va rouvrir l'application, la databaseKey sera récupérée depuis le backend via une session authentifiée, et cette clé sera utilisée pour déchiffrer l'encryptedDB.

uml diagram

Tout cela se fait sans aucune interaction utilisateur.

Stockage de la base de données

Pour stocker la base de données, nous allons modifier la fonction instantiateSealdSDK pour sauvegarder la base de données en localStorage de façon chiffrée.

Pour cela, on ajoute un argument databasePath pour enregistrer la base de données, et un argument databaseKey pour la chiffrer.

TIP

Cet exemple fait le choix d'utiliser une databaseKey différente par session. Pour une implémentation plus simple, vous pouvez plutôt choisir de n'utiliser qu'une seule et même databaseKey par utilisateur pour toutes les sessions.

ts
const instantiateSealdSDK = async ({ databaseKey, sessionID }: { databaseKey: string, sessionID: string }): Promise<void> => {
  sealdSDKInstance = SealdSDKConstructor({
    appId: await getSetting('APP_ID'),
    apiURL: await getSetting('API_URL'), // Optional. If not set, defaults to public apiURL https://api.seald.io
    databaseKey,
    databasePath: `seald-example-project-session-${sessionID}`,
    plugins: [
      SealdSDKPluginSSKSPassword(await getSetting('KEY_STORAGE_URL')) // Optional. If not set, defaults to public keyStorageURL https://ssks.seald.io
    ]
  })
  await sealdSDKInstance.initialize()
}

TIP

On rajoute sessionID dans le databasePath afin que celui-ci soit unique pour chaque session.

En effet, si un même utilisateur se déconnecte, puis se reconnecte, le serveur aura généré une nouvelle databaseKey. On ne peut donc pas utiliser la même base de données : il faut que la base de données soit unique pour chaque session, on fait donc varier le databasePath en fonction du sessionID.

Lorsque l'on appelle la fonction createIdentity, l'identité est à la fois sauvegardée sur SSKS par le mot de passe connu de l'utilisateur, et en localStorage protégée avec la databaseKey.

De même, quand on appelle retrieveIdentity, l'identitée récupérée sur SSKS est stockée en localStorage protégée avec la databaseKey.

Nous verrons par la suite comment gérer cette databaseKey.

TIP

Il est possible de créer une sous-identité différente pour la protection par mot de passe et pour la base de données locale.

Ici, par souci de simplicité, la même identité sera dans la base de donnée locale et protégée par mot de passe.

Récupération de l'identité

Pour récupérer l'identité depuis le localStorage, on va créer une fonction retrieveIdentityFromLocalStorage qui instancie le SDK et vérifie que la base de données est dans l'état attendu :

ts
export const retrieveIdentityFromLocalStorage = async ({ databaseKey, sessionID }: { databaseKey: string, sessionID: string }): Promise<string> => {
  await instantiateSealdSDK({ databaseKey, sessionID })
  await sealdSDKInstance.initialize()
  const status = await sealdSDKInstance.registrationStatus()
  if (status !== 'registered') {
    throw new Error('Not registered')
  }
  const accountInfo = await sealdSDKInstance.getCurrentAccountInfo()
  return accountInfo.sealdId
}

Lorsque que l'on appelle la fonction retrieveIdentityFromLocalStorage, la base de données est récupérée depuis le localStorage, déchiffrée avec la databaseKey et chargée dans le SDK. Nous verrons par la suite comment gérer cette databaseKey.

Gestion de databaseKey

La databaseKey est un secret qui protège la base de donnée enregistrée en localStorage, et donc l'identité de l'utilisateur. Elle doit être récupérable par l'utilisateur de façon authentifiée.

Nous allons la générer depuis le backend, la stocker en session, et modifier le client API du frontend en conséquence.

TIP

Pour une instanciation plus rapide, vous pouvez utiliser une databaseRawKey à la place de la databaseKey. Pour plus de détails, allez voir le paragraphe "Utilisation d'une base de données persistante" du guide sur les identités.

Modification de l'API en backend

Pour cela, nous allons modifier les routes suivantes dans le fichier backend/routes/account.js :

  • de création de compte : POST /account/ pour générer, stocker et rendre la databaseKey et le sessionID ;
  • de connexion : POST /account/login pour générer, stocker et rendre la databaseKey et le sessionID ;
  • de déconnexion : GET /account/logout pour supprimer la databaseKey ;
  • de statut : GET /account/ pour rendre la databaseKey et le sessionID.

Avant de modifier les routes, il faut importer le module crypto pour générer des databaseKey aléatoires avec fonction  :

js
import { randomBytes as _randomBytes } from 'crypto'
import { promisify } from 'util'

const randomBytes = promisify(_randomBytes)

Dans les routes de création de compte et de connexion, on génère et rend la databaseKey :

js
/* ... */
req.session.databaseKey = (await randomBytes(64)).toString('base64')
res.json({
  user: user.serialize(),
  databaseKey: req.session.databaseKey,
  sessionID: req.sessionID
})

Dans la route de statut, on ne fait que rendre la databaseKey :

js
/* ... */
res.json({
  user: user.serialize(),
  databaseKey: req.session.databaseKey,
  sessionID: req.sessionID
})

Dans la route de déconnexion, on supprime la databaseKey de la session :

js
/* ... */
delete req.session.databaseKey

Modification du client API en frontend

Nous allons répercuter ces modifications dans le client d'API frontend/src/services/api.ts.

Pour cela il faut :

  • stocker databaseKey et sessionID en propriétés de la classe User
  • les récupérer après les appels API dans les méthodes statiques User.createAccount, User.login et User.updateCurrentUser.
ts
class User {
  readonly id: string
  readonly name: string
  readonly emailAddress: string
  sealdId?: string
  readonly signupJWT?: string
  readonly databaseKey?: string
  readonly sessionID?: string

  constructor ({ id, name, emailAddress, sealdId, signupJWT, databaseKey, sessionID }: { id: string, name: string, emailAddress: string, sealdId?: string, signupJWT?: string, sessionID?: string, databaseKey?: string }) {
    this.id = id
    this.name = name
    this.emailAddress = emailAddress
    if (sealdId != null) this.sealdId = sealdId
    if (signupJWT != null) this.signupJWT = signupJWT
    if (databaseKey != null) this.databaseKey = databaseKey // only for currentUser
    if (sessionID != null) this.sessionID = sessionID // only for currentUser
  }

  /* ... */
  static async createAccount ({ emailAddress, password, name }: CreateAccountType): Promise<User> {
    const preDerivedPassword = await preDerivePassword(password, emailAddress)
    const { user: { id }, databaseKey, sessionID, signupJWT } = await apiClient.rest.account.create({
      emailAddress,
      password: preDerivedPassword,
      name
    })
    currentUser = new this({
      id,
      emailAddress,
      name,
      signupJWT,
      databaseKey,
      sessionID
    })
    return currentUser
  }

  static async login ({ emailAddress, password }: LoginType): Promise<User> {
    const preDerivedPassword = await preDerivePassword(password, emailAddress)
    const { user: { id, name, sealdId }, databaseKey, sessionID } = await apiClient.rest.account.login({
      emailAddress,
      password: preDerivedPassword
    })
    currentUser = new this({
      id,
      emailAddress,
      name,
      sealdId,
      databaseKey,
      sessionID
    })
    return currentUser
  }

  static async updateCurrentUser (): Promise<User> {
    const { user: { id, emailAddress, name, sealdId }, databaseKey, sessionID } = await apiClient.rest.account.status()
    currentUser = new this({
      id,
      emailAddress,
      name,
      sealdId,
      databaseKey,
      sessionID
    })
    return currentUser
  }
  /* ... */
}

Récupération depuis le localStorage à l'initialisation

La dernière étape est d'appeler retrieveIdentityFromLocalStorage au chargement de l'application dans la fonction init de frontend/App.tsx.

Il faut :

  • tenter un appel GET /account/ pour vérifier si le navigateur dispose d'une session authentifiée, et récupérer databaseKey et sessionID ;
  • tenter de récupérer et déchiffrer la base de données stockée en localStorage.

En cas d'erreur sur l'une de ces deux étapes, on définit le currentUser à null ce qui va emmener l'utilisateur sur l'étape de connexion.

tsx
const init = async (): Promise<void> => {
  try {
    let currentUser
    // We need to retrieve :
    // 1/ the profile of the user (userId, name, etc.) & databaseKey
    // 2/ its Seald identity from the localStorage
    // If any of these steps fail, we need to log in again.
    try {
      currentUser = await User.updateCurrentUser() // Retrieve the profile & databaseKey
      if (currentUser.id === '' || currentUser.databaseKey == null || currentUser.sessionID == null) { // Check we got at least the id, databaseKey & sessionID
        await (User.logout().catch(() => {})) // if not, let's log out gracefully
        throw new Error('Retrieved profile incomplete') // and skip to catch
      }
      const sealdId = await retrieveIdentityFromLocalStorage({ // We try to retrieve the Seald identity from localStorage
        databaseKey: currentUser.databaseKey,
        sessionID: currentUser.sessionID
      })
      currentUser.sealdId = sealdId
    } catch (error) {
      console.error(error)
      currentUser = null
    }
    /* ... */
  } catch (error) { /* ... */ }
}

Conclusion

L'identité est désormais mise en cache, un utilisateur connecté peut donc fermer la fenêtre de chat et la rouvrir sans devoir retaper son mot de passe.

On a désormais une version fonctionnelle et robuste d'un chat chiffré de bout-en-bout utilisant le SDK Seald et la protection par mot de passe.

Néanmoins, si l'utilisateur oublie son mot de passe, il ne pourra plus déchiffrer ses données.

Une solution à ce problème est de remplacer la protection de l'identité par mot de passe par une protection de l'identité en 2-man-rule.