Skip to content

Persistent database in localStorage

In the previous step, we added a password pre-derivation step on the authentication method to ensure the security of password protection of a user's Seald identity.

We are still facing the problem that when a user opens a new tab in the application, despite having an authenticated session, the user has to retype their password to recover their Seald identity.

In this step, we will add an identity cache in localStorage so that the user can open a new tab.

The branch on which this step is based is 2-pre-derivation, the final result is 3-localstorage.

Explication

In the mechanism implemented in the previous steps, the Seald identity of a user is retrieved from SSKS and then decrypted using the user's password. Thus, this identity is only kept in memory on the client side.

It is however desirable that when a user refreshes the page, or opens a new tab, they have access to their Seald identity without retyping a password.

The solution we recommend is to cache the database in the browser in the localStorage, and as a precaution, we recommend to protect this database in localStorage by a key stored in the backend called databaseKey.

TIP

The Seald database uses nedb, which in the browser does not necessarily use the localStorage, but choses (through localForage) the best storage method between IndexedDB, WebSQL or localStorage depending on your browser.

uml diagram

When the user reopens the application, the databaseKey will be retrieved from the backend via an authenticated session, and this key will be used to decrypt the encryptedDB.

uml diagram

All this is done without any user interaction.

Storing the database

To store the database, we will modify the createIdentity function to save the database created in localStorage in encrypted format.

To do this, we add a databasePath argument to store the database, and a databaseKey argument to encrypt it.

TIP

This example choses to use a different databaseKey per session. For a simpler implementation, you can instead opt to use a single databaseKey per user for all their sessions.

js
const instantiateSealdSDK = async ({ databaseKey, sessionID }) => {
  sealdSDKInstance = SealdSDK({
    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()
}
const instantiateSealdSDK = async ({ databaseKey, sessionID }) => {
  sealdSDKInstance = SealdSDK({
    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

We add sessionID in the databasePath in order for it to be unique per-session.

Indeed, if a user logs-out, then logs-in again, the server will have generated a new databaseKey. Consequently, we cannot use the same database: the database needs to be unique for each session, so we make the databasePath change with sessionID.

When we call the createIdentity function, the identity is both saved on SSKS by the password known by the user, and in localStorage protected with the databaseKey.

Similarly, when we call retrieveIdentity, the identity retrieved from SSKS is stored in localStorage encrypted with the databaseKey.

We will see later how to manage this databaseKey.

TIP

It is possible to create a different sub-identity for password protection and for protecting the local database.

Here, for simplicity, the same identity will be in the local database and protected by password.

Identity retrieval

To retrieve the identity from the localStorage, we will create a function retrieveIdentityFromLocalStorage which instantiates the SDK and checks that the database is in the expected state:

js
export const retrieveIdentityFromLocalStorage = async ({ databaseKey, sessionID }) => {
  await instantiateSealdSDK({ databaseKey, sessionID })
  const status = await sealdSDKInstance.registrationStatus()
  if (status !== 'registered') {
    throw new Error('Not registered')
  }
}
export const retrieveIdentityFromLocalStorage = async ({ databaseKey, sessionID }) => {
  await instantiateSealdSDK({ databaseKey, sessionID })
  const status = await sealdSDKInstance.registrationStatus()
  if (status !== 'registered') {
    throw new Error('Not registered')
  }
}

When calling the retrieveIdentityFromLocalStorage function, the database is retrieved from the localStorage, decrypted with the databaseKey and loaded in the SDK. We will see later how to manage this databaseKey.

Managing the databaseKey

The databaseKey is a secret that protects the database stored in the localStorage, including the user's identity. It must be recoverable by the user in an authenticated way.

We will generate it from the backend, store it in session, and modify the frontend API client accordingly.

TIP

For faster instantiation, you can use a databaseRawKey instead of the databaseKey. For more details, see the "Using a persistent local database" section of the Identity Guide.

Modification of the API in the backend

To do this, we will modify the following routes in the backend/routes/account.js file:

  • account creation: POST /account/ to generate, store and return the databaseKey & sessionID;
  • login: POST /account/login to generate, store and return the databaseKey & sessionID;
  • logout: GET /account/logout to delete the databaseKey;
  • status: GET /account/ to return the databaseKey & sessionID.

Before modifying the routes, you have to import the crypto module to generate random databaseKey with function :

js
const crypto = require('crypto')
const util = require('util')

const randomBytes = util.promisify(crypto.randomBytes)
const crypto = require('crypto')
const util = require('util')

const randomBytes = util.promisify(crypto.randomBytes)

In the account creation and login routes, the databaseKey is generated and returned:

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

In the status route, we return the databaseKey:

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

In the logout route, we remove the databaseKey from the session:

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

Modification of the API client in the frontend

We will reflect these changes in the API client frontend/services/api.js.

To do this we need to:

  • store this databaseKey & sessionID as a properties of the User class
  • retrieve them after the API calls in the static methods User.createAccount, User.login and User.updateCurrentUser.
js
class User {
  constructor ({ id, name, emailAddress, databaseKey, sessionID }) {
    this.id = id
    this.name = name
    this.emailAddress = emailAddress
    this.databaseKey = databaseKey // only for currentUser
    this.sessionID = sessionID // only for currentUser
  }

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

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

  static async updateCurrentUser () {
    const { user: { id, emailAddress, name }, databaseKey, sessionID } = await apiClient.rest.account.status()
    currentUser = new this({
      id,
      emailAddress,
      name,
      databaseKey,
      sessionID
    })
    return currentUser
  }
  /* ... */
}
class User {
  constructor ({ id, name, emailAddress, databaseKey, sessionID }) {
    this.id = id
    this.name = name
    this.emailAddress = emailAddress
    this.databaseKey = databaseKey // only for currentUser
    this.sessionID = sessionID // only for currentUser
  }

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

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

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

Retrieving from localStorage at initialization

The last step is to call retrieveIdentityFromLocalStorage at application load in the init function of frontend/App.js.

We need to:

  • attempt a GET /account/ call to check if the browser has an authenticated session, and retrieve the databaseKey & sessionID;
  • try to retrieve and decrypt the database stored in localStorage.

If there is an error on either of these two steps, we set the currentUser to null which will take the user to the login step.

js
const init = async () => {
  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 || !currentUser.sessionID) { // 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
      }
      await retrieveIdentityFromLocalStorage({ // We try to retrieve the Seald identity from localStorage
        databaseKey: currentUser.databaseKey,
        sessionID: currentUser.sessionID
      })
    } catch (error) {
      console.error(error)
      currentUser = null
    }
    /* ... */
  } catch (error) { /* ... */ }
}
const init = async () => {
  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 || !currentUser.sessionID) { // 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
      }
      await retrieveIdentityFromLocalStorage({ // We try to retrieve the Seald identity from localStorage
        databaseKey: currentUser.databaseKey,
        sessionID: currentUser.sessionID
      })
    } catch (error) {
      console.error(error)
      currentUser = null
    }
    /* ... */
  } catch (error) { /* ... */ }
}

Conclusion

The identity is now cached, so a logged-in user can close the chat window and reopen it without having to retype their password.

We now have a functional and robust version of an end-to-end encrypted chat using the Seald-SDK and password protection.

However, if the user forgets their password, they will not be able to decrypt their data.

One solution to this problem is to replace the password-based identity protection with 2-man-rule identity protection.