Skip to content

Protection with 2-man-rule

In the previous step, we had achieved a satisfying version from a security point of view.

Nevertheless, this version still has a user experience drawback: if the user loses their password, they can no longer recover their Seald identity, and thus lose the ability to decrypt their data.

In this step, we will replace the password-protection with the 2-man rule protection method. This method requires a challenge to be sent to the user. Initially, this challenge will be sent by email, then we will see how to send it by SMS.

The branch on which this step is based is 3-localstorage, the result with the email challenge is 4-two-man-rule. The implementation with the SMS challenge is 5-two-man-rule-sms.

Explanation

As explained in the dedicated guide, the purpose of the 2-man-rule protection mode is to give users "the right" to forget their password. The downside is that this mode is not strictly end-to-end.

It works by "splitting" the identity in two, and placing one half - called twoManRuleKey - on the backend subject to authentication, and the other half - called encryptedIdentity - on Seald SDK Key Storage (SSKS) subject to independent email confirmation authentication.

To implement 2-man-rule protection you need to:

  • modify the backend to:
    • store a twoManRuleKey for each user in database ;
    • expose an authenticated API point to generate, re-engage and retrieve the twoManRuleKey, and for the frontend to interact with SSKS to store & retrieve the second half of the encryptedIdentity key;
  • modify the frontend to:
    • replace the password protection plugin with the @seald-io/sdk-plugin-ssks-2mr plugin for 2-man-rule protection;
    • manage the independent authentication step by SSKS.

TIP

For faster execution, you can use a rawTwoManRuleKey instead of the twoManRuleKey. For more details, see the "Using a raw TwoManRule Key" paragraph of the identities guide.

Modifying the backend

We will do the following:

  • modify the User model to be able to store a twoManRuleKey for each user in database;
  • add variables in the configuration file to authenticate the backend connection to SSKS;
  • expose a sendChallenge2MR API point to generate/retrieve the twoManRuleKey and create a session between the frontend and SSKS to drop/retrieve the second half of the encryptedIdentity key.

Modification of the User model

In the backend/models.js file, we add the twoManRuleKey property to the User model initialization:

js
const { Sequelize, DataTypes, Model, ValidationError } = require('sequelize')

/* ... */

class User extends Model { /* ... */ }

/* ... */

User.init(
  {
    /* ... */
    twoManRuleKey: {
      type: DataTypes.STRING
    }
    /* ... */
  }
)

/* ... */

Configuration file

We add two variables to the backend configuration file:

NameDefault valueRequiredDescription
KEY_STORAGE_URLundefinedYesURL of SSKS, available on the Seald administration panel
KEY_STORAGE_URL_APP_KEYundefinedYesAPI key ssksAppKey, available on the Seald administration panel

To learn where you can get these, you can check the paragraph about it in the 2-man-rule integration guide.

API point sendChallenge2MR

This API point does in one request the two things described in the dedicated integration guide:

  • it generates & stores if needed, then returns the twoManRuleKey associated with a user in your application;
  • it uses the SSKS API for the user to authenticate to SSKS.

In the backend/routes/account.js file, we will add an authenticated POST route '/sendChallenge2MR'.

To interact with SSKS we will use node-fetch, so we need to install it:

bash
cd backend
npm install node-fetch

Then import it in the file backend/routes/account.js:

js
import fetch from 'node-fetch'

Finally, we create the /sendChallenge2MR route which will:

  • instruct SSKS, if the user authentication is necessary, to send an email in the format of the template to the user's email address that will contain a challenge (valid for 6h) that the user will return to SSKS in the session identified by the twoManRuleSessionId;
  • generate, store & return the twoManRuleKey to the user;
  • inform the frontend if an authentication is necessary to store the identity on SSKS.
js
// We create the authenticated route `/sendChallenge2MR`
router.post('/sendChallenge2MR',
  isAuthenticatedMiddleware,
  async (req, res, next) => {
    try {
      // we retrieve the user from the database using their ID in session
      const user = await User.findByPk(req.session.user.id)
      // if we don't have a `twoManRuleKey` for this user, it means that we have
      // to create a user on SSKS
      const createUser = user.twoManRuleKey == null
      // we make a POST on SSKS_URL/tmr/back/challenge_send/
      const sendChallengeResult = await fetch(
        `${settings.KEY_STORAGE_URL}tmr/back/challenge_send/`,
        {
          method: 'POST',
          credentials: 'omit',
          headers: {
            'Content-Type': 'application/json',
            'X-SEALD-APPID': settings.APP_ID,
            'X-SEALD-APIKEY': settings.KEY_STORAGE_APP_KEY
          },
          body: JSON.stringify({
            create_user: createUser, // boolean determined above
            user_id: user.id, // unique identifier for the user in this app
            auth_factor: {
              type: 'EM',
              value: user.emailAddress // email address of the user
            },
            template: '<html><body>Challenge: $$CHALLENGE$$</body></html>' // email template to use
          })
        }
      )
      // handling the error case
      if (!sendChallengeResult.ok) {
        const responseText = await sendChallengeResult.text()
        throw new Error(`Error in SSKS createUser: ${sendChallengeResult.status} ${responseText}`)
      }
      // retrieval of the session id which will be used by the user
      const { session_id: twoManRuleSessionId, must_authenticate: mustAuthenticate } = await sendChallengeResult.json()
      // if there is no `twoManRuleKey` stored yet, we generate a new one
      if (createUser) {
        user.twoManRuleKey = (await randomBytes(64)).toString('base64')
        await user.save()
      }
      // response to the user
      res.status(200).json({
        twoManRuleSessionId,
        twoManRuleKey: user.twoManRuleKey,
        mustAuthenticate
      })
    } catch (error) {
      next(error)
    }
  })

TIP

It is important to give a valid email address or phone number, as well as a template containing $$CHALLENGE$$ in a visible way, since SSKS will replace $$CHALLENGE$$ with a random challenge that the user will have to copy.

Now that we have made all the necessary changes to the backend, let's move on to the frontend.

Modification of the frontend

We will do the following:

  • modify the API client frontend/src/services/api.ts to include this new API point, and remove the previously added password pre-derivation, which is useless with the 2-man-rule protection mode;
  • modify the Seald service frontend/src/services/seald.ts to modify the identity creation and retrieval functions;
  • add, if requested by the backend (if mustAuthenticate is true), the email confirmation by SSKS step in the UI in the SignIn.tsx and SignUp.tsx.

Client API

In the frontend/src/services/api.ts file, add the sendChallenge2MR: body => POST('/account/sendChallenge2MR', body) route to the APIClient, then add a static sendChallenge2MR method to the User class:

ts
class User {
  /* ... */
  static async sendChallenge2MR (): Promise<{ twoManRuleSessionId: string, twoManRuleKey: string, mustAuthenticate: boolean }> {
    return await apiClient.rest.account.sendChallenge2MR()
  }
  /* ... */
}

Then, we can remove the pre-derivation of the password in the createAccount and login methods.

To ease future calls, we create a function sendChallenge2MR which is an alias for the above function:

js
export const sendChallenge2MR = async () => {
  return User.sendChallenge2MR()
}

Seald service

Configuring

We start by installing the plugin @seald-io/sdk-plugin-ssks-2mr, remove the plugin @seald-io/sdk-plugin-ssks-password and scyrpt:

bash
cd frontend
npm install @seald-io/sdk-plugin-ssks-2mr

npm uninstall @seald-io/sdk-plugin-ssks-password scrypt

Then, in the file frontend/src/services/seald.ts, import it and add it to the SDK. We also import User which we will need later:

ts
import SealdSDKPluginSSKS2MR from '@seald-io/sdk-plugin-ssks-2mr/browser'
import { User } from './api.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: [SealdSDKPluginSSKS2MR(await getSetting('KEY_STORAGE_URL'))] // Optional. If not set, defaults to public keyStorageURL https://ssks.seald.io
  })
}

Identity creation and storage

Saving the identity is now done in two steps:

  • the request to send the challenge;
  • storing the identity on SSKS, with or without a challenge depending on the value of mustAuthenticate.

To do this, we modify the createIdentity function which no longer needs the password as an argument:

ts
export const createIdentity = async ({ signupJWT, databaseKey, sessionID }: { signupJWT: string, databaseKey: string, sessionID: string }): Promise<string> => {
  await instantiateSealdSDK({ databaseKey, sessionID })
  const accountInfo = await sealdSDKInstance.initiateIdentity({ signupJWT })
  return accountInfo.sealdId
}

Also, immediately after its call in frontend/src/containers/SignUp.tsx, we request the sending of a challenge using the API point created above:

ts
import { sendChallenge2MR } from '../services/seald'
/* ... */
const { twoManRuleSessionId, twoManRuleKey, mustAuthenticate } = await sendChallenge2MR()

This function returns an object containing twoManRuleKey, twoManRuleSessionId (not to confuse with the sessionID of the client authentication on the backend), and mustAuthenticate, sent by the backend.

If mustAuthenticate is true, SSKS sends an email. This email or text message will contain a challenge valid for 6h, which will need to be copied by the user.

If mustAuthenticate is false, storing the identity on SSKS can be done without a challenge.

We create a saveIdentity2MR function that will store the identity on SSKS:

ts
export const saveIdentity2MR = async ({ userId, twoManRuleKey, emailAddress, twoManRuleSessionId, challenge }: { userId: string, twoManRuleKey: string, emailAddress: string, twoManRuleSessionId: string, challenge?: string }): Promise<void> => {
  await sealdSDKInstance.ssks2MR.saveIdentity({
    challenge,
    authFactor: {
      type: 'EM',
      value: emailAddress
    },
    twoManRuleKey,
    userId,
    sessionId: twoManRuleSessionId
  })
}

Once this function is called, the identity will be saved on SSKS.

Identity Recovery

When recovering an indentity, authentication with SSKS is mandatory.

The identity recovery is now done in two steps:

  • sending the challenge;
  • using the challenge to retrieve the identity from SSKS.

We change function retrieveIdentity to retrieveIdentity2MR which doesn't take password as argument anymore, but takes as new arguemtns:

  • emailAddress: email address of the user;
  • twoManRuleKey: key stored by the backend;
  • twoManRuleSessionId: session id created by the backend for the user at SSKS;
  • challenge: random challenge sent by e-mail.

And we simply change the plugin used to call the sealdSDKInstance.ssks2MR.retrieveIdentity function with these arguments:

ts
export const retrieveIdentity2MR = async ({ userId, emailAddress, twoManRuleKey, twoManRuleSessionId, challenge, databaseKey, sessionID }: { userId: string, twoManRuleKey: string, emailAddress: string, twoManRuleSessionId: string, challenge: string, databaseKey: string, sessionID: string }): Promise<string> => {
  await instantiateSealdSDK({ databaseKey, sessionID })
  await sealdSDKInstance.ssks2MR.retrieveIdentity({
    challenge,
    authFactor: {
      type: 'EM',
      value: emailAddress
    },
    twoManRuleKey,
    userId,
    sessionId: twoManRuleSessionId
  })
}

After calling this function, the identity will be retrieved and the SDK will be usable.

WARNING

It is recommended not to retrieve the same identity with ssks2MR.retrieveIdentity on multiple devices at the same time, at the same exact instant, for example during automated tests. Please wait until one of the devices has finished retrieving the identity before starting the retrieval on another device.

Modification of the user interface

From now on, you have to call these functions:

  • at account creation;
  • at login.

Account creation SignUp.tsx

We start by adding two status hooks:

ts
const [challengeSession, setChallengeSession] = useState<{
  twoManRuleSessionId: string
  twoManRuleKey: string
  emailAddress: string
} | null>(null)
const [currentUser, setCurrentUser] = useState<User | null>(null)

Then, we add a ChallengeForm component which is a form allowing the user, when mustAuthenticate is true, to type the challenge and which calls handleChallengeSubmit when the form is validated:

tsx
function ChallengeForm () {
  return (
    <form className={classes.form} onSubmit={handleChallengeSubmit}>
      <TextField
        variant='outlined'
        margin='normal'
        required
        fullWidth
        id='challenge'
        label='challenge'
        name='challenge'
        autoFocus
      />
      <div className={classes.wrapperButton}>
        <Button type='submit' disabled={isLoading} fullWidth variant='contained' color='primary' className={classes.submit}>
          Sign up
        </Button>
        {isLoading && <CircularProgress size={24} className={classes.buttonProgress} />}
      </div>
    </form>
  )
}

This ChallengeForm will be used instead of the SignupForm if challengeSession is defined:

tsx
return (
  <Container component='main' maxWidth='xs'>
    {/* ... */}
    {challengeSession == null ? <SignupForm /> : <ChallengeForm />}
    {/* ... */}
  </Container>
)

Next, we modify the end of the handleSignupSubmit so that it displays the ChallengeForm to the user if mustAuthenticate is true, and so that it stores the identity directly otherwise:

tsx
const handleSignupSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
  event.preventDefault()
  setIsLoading(true)
  const asyncHandleSignupSubmit = async (): Promise<void> => {
    const formData = new FormData(event.target as HTMLFormElement)
    try {
      const emailAddress = formData.get('emailAddress') as string
      const password = formData.get('password') as string
      const name = formData.get('name') as string
      const currentUser = await User.createAccount({ emailAddress, password, name })
      // we don't call `createIdentity` with the password anymore
      // and we get three elements we'll store for the next step:
      // `twoManRuleSessionId`, `twoManRuleKey`, and `mustAuthenticate`
      const sealdId = await createIdentity({
        signupJWT: currentUser.signupJWT!,
        databaseKey: currentUser.databaseKey!,
        sessionID: currentUser.sessionID! // This sessionID is unrelated to 2-man-rule, it is used for database caching in localStorage
      })
      await currentUser.setSealdId(sealdId)
      const { twoManRuleSessionId, twoManRuleKey, mustAuthenticate } = await sendChallenge2MR()
      // We store the currentUser in its state hook
      setCurrentUser(currentUser)
      // If `mustAuthenticate` is `true`, we store the challenge info in its state hook to display the ChallengeForm
      if (mustAuthenticate) {
        setChallengeSession({
          twoManRuleSessionId,
          twoManRuleKey,
          emailAddress
        })
        console.log('session set')
      } else { // If `mustAuthenticate` is `false`, we can directly save the identity
        await saveIdentity2MR({
          userId: currentUser.id,
          emailAddress,
          twoManRuleKey,
          twoManRuleSessionId,
          challenge: undefined
        })
        dispatch({ type: SocketActionKind.SET_AUTH, payload: { currentUser } })
        dispatch({
          type: SocketActionKind.SET_ROOMS,
          payload: {
            rooms: await Room.list()
          }
        })
      }
    } catch (error) {
      enqueueSnackbar(getMessageFromUnknownError(error), {
        variant: 'error'
      })
    } finally {
      setIsLoading(false)
    }
  }
  void asyncHandleSignupSubmit()
},
[enqueueSnackbar, dispatch, setCurrentUser, setChallengeSession]
)

Finally, we create handleChallengeSubmit which uses the challenge completed by the user to save his identity on SSKS:

tsx
import { saveIdentity2MR } from '../services/seald'
/* ... */

const handleChallengeSubmit = useCallback((event: FormEvent<HTMLFormElement>): void => {
    event.preventDefault()
    setIsLoading(true)
    const handleChallengeSubmitAsync = async (): Promise<void> => {
      const formData = new FormData(event.target as HTMLFormElement)
      try {
        setIsLoading(true)
        const challenge = formData.get('challenge') as string ?? undefined
        if (currentUser == null) throw new Error('currentUser is not defined')
        if (challengeSession == null) throw new Error('challengeSession is not defined')
        await saveIdentity2MR({
          userId: currentUser.id,
          emailAddress: challengeSession.emailAddress,
          twoManRuleKey: challengeSession.twoManRuleKey,
          twoManRuleSessionId: challengeSession.twoManRuleSessionId,
          challenge
        })
        // the end of this function `handleChallengeSubmit` is identical to the end
        // of the previous version of `handleSignupSubmit`
        dispatch({ type: SocketActionKind.SET_AUTH, payload: { currentUser } })
        dispatch({
          type: SocketActionKind.SET_ROOMS,
          payload: {
            rooms: await Room.list()
          }
        })
      } catch (error) {
        enqueueSnackbar(getMessageFromUnknownError(error), {
          variant: 'error'
        })
      } finally {
        setIsLoading(false)
      }
    }

    void handleChallengeSubmitAsync()
  },
  [enqueueSnackbar, dispatch, challengeSession, currentUser]
)

That's it, we have implemented all the changes in the frontend.

2-man-rule by SMS

Once the two-man-rule with an email challenge has been implemented, it is very easy to switch to the SMS challenge. We will need to change the frontend to ask the user for a phone number, and then record it in the backend database. We will then make some minor changes to the @seald-io/sdk-plugin-ssks-2mr plugin.

Modifying the User model in the backend

In the backend/models.js file, we add the phoneNumber property to the User model initialization:

js
const { Sequelize, DataTypes, Model, ValidationError } = require('sequelize')

/* ... */

class User extends Model { /* ... */ }

/* ... */

User.init(
  {
    /* ... */
    phoneNumber: {
      type: DataTypes.STRING,
      allowNull: false
    }
    /* ... */
  }
)

/* ... */

Modification of the API point and the associated validator

We need to add a phoneNumber field in the query validator:

js
const createAccountValidator = {
  body: Joi.object({
    emailAddress: Joi.string().email().required().max(255),
    name: Joi.string().required().max(255),
    phoneNumber: Joi.string().required().max(15),
    password: Joi.string().required().max(255)
  })
}

The phoneNumber field should then be retrieved and passed to the model:

js
router.post('/', validate(createAccountValidator), async (req, res, next) => {
  /* ... */
  const { emailAddress, password, phoneNumber, name } = req.body
  const user = await User.create({ emailAddress, password, phoneNumber, name })
  /* ... */
}

Modifying the sendChallenge2MR API point

The changes here are easy. The auth_factor needs to be changed. Its type changes from 'EM' to 'SMS', and its value changes to contain the phone number.

Secondly, the template field needs to be changed. This no longer contains the HTML code of the email, but the text of the SMS. Again the template must contain the key $$CHALLENGE$$ which will be replaced by the challenge.

js
router.post('/', validate(createAccountValidator), async (req, res, next) => {
  /* ... */      
    body: JSON.stringify({
      create_user: createUser,
      user_id: user.id,
      auth_factor: {
        type: 'SMS',
        value: user.phoneNumber
      },
      template: 'Challenge: $$CHALLENGE$$'
    })
  /* ... */
}

Fronted modifications

The modifications on the frontend are very similar to those on the backend. You will need to:

  • Add a phone number field to the SignupForm.
  • Add a phoneNumber key in the user model.
  • Modify the account creation API point and call to include the phone number.

Once these three changes have been made, change the saveIdentity2MR and retrieveIdentity2MR functions in the seald.js file. We need to add a phoneNumber argument to these functions, and then change the authFactor argument when calling the plugin functions. Again, the type of the authentication factor becomes 'SMS', and its value becomes the phone number.

ts
export const saveIdentity2MR = async ({ userId, twoManRuleKey, phoneNumber, twoManRuleSessionId, challenge }: { userId: string, twoManRuleKey: string, phoneNumber: string, twoManRuleSessionId: string, challenge?: string }): Promise<void> => {
  await sealdSDKInstance.ssks2MR.saveIdentity({
    challenge,
    authFactor: {
      type: 'SMS',
      value: phoneNumber
    },
    twoManRuleKey,
    userId,
    sessionId: twoManRuleSessionId
  })
}
export const retrieveIdentity2MR = async ({ userId, phoneNumber, twoManRuleKey, twoManRuleSessionId, challenge, databaseKey, sessionID }: { userId: string, twoManRuleKey: string, phoneNumber: string, twoManRuleSessionId: string, challenge: string, databaseKey: string, sessionID: string }): Promise<string> => {
  await instantiateSealdSDK({ databaseKey, sessionID })
  await sealdSDKInstance.ssks2MR.retrieveIdentity({
    challenge,
    authFactor: {
      type: 'SMS',
      value: phoneNumber
    },
    twoManRuleKey,
    userId,
    sessionId: twoManRuleSessionId
  })
}

Conclusion

We were able to implement the 2-man-rule protection completely, improvements can still be made, for example instead of asking the user to copy the challenge it is possible to include it in a magic link to the application (be careful not to use a GET variable but a URL fragment so that the server does not know the challenge).