Skip to content

Protection en 2-man-rule

Lors de l'étape précédente, nous avions abouti à une version satisfaisante d'un point de vue sécurité.

Néanmoins, cette version a toujours un défaut d'expérience utilisateur : si l'utilisateur perd son mot de passe, il ne peut plus récupérer son identité Seald, et perd donc la capacité de déchiffrer ses données.

Lors de cette étape, nous remplacerons la méthode de protection de l'identité par mot de passe par la méthode de protection en 2-man rule. Cette méthode nécessite l'envoi d'un challenge à l'utilisateur. Dans un premier temps, ce challenge sera envoyé par email, puis nous verrons comment l'envoyer par SMS.

La branche sur laquelle est basée cette étape est 3-localstorage , le résultat avec le challenge par email est 4-two-man-rule . L'implémentation avec le challenge par SMS est 5-two-man-rule-sms.

Explication

Tel qu'expliqué dans le guide dédié, l'objectif du mode de protection en 2-man-rule est de donner aux utilisateurs "le droit" d'oublier leur mot de passe. La contrepartie est que ce mode n'est pas strictement de bout-en-bout.

Son fonctionnement consiste à "couper" en deux l'identité, et à placer une moitié — appelée twoManRuleKey — sur le backend soumis à authentification, et l'autre moitié — appelée encryptedIdentity — sur Seald SDK Key Storage (SSKS) soumis à une authentification indépendante par email de confirmation.

Pour implémenter la protection en 2-man-rule il faut :

  • modifier le backend pour :
    • stocker une twoManRuleKey pour chaque utilisateur en base de données  ;
    • exposer un point d'API authentifié pour générer, rengistrer et récupérer la twoManRuleKey, et pour que le frontend puisse interagir avec SSKS afin de déposer & récupérer la deuxième moitié de la clé encryptedIdentity ;
  • modifier le frontend pour :
    • remplacer le plugin de protection par mot de passe par le plugin @seald-io/sdk-plugin-ssks-2mr de protection en 2-man-rule ;
    • gérer l'étape d'authentification indépendante par SSKS.

TIP

Pour une exécution plus rapide, vous pouvez utiliser une rawTwoManRuleKey à la place de la twoManRuleKey. Pour plus de détails, allez voir le paragraphe "Utilisation d'une TwoManRule Key brute" du guide sur les identités.

Modification du backend

Nous allons dans l'ordre :

  • modifier le modèle User pour pouvoir stocker une twoManRuleKey pour chaque utilisateur en base de données ;
  • ajouter des variables dans le fichier de configuration pour authentifier la connexion du backend à SSKS ;
  • exposer un point d'API sendChallenge2MR permettant de générer / récupérer la twoManRuleKey et de créer une session entre le frontend et SSKS afin d'y déposer /récupérer la deuxième moitié de la clé encryptedIdentity.

Modification du modèle User

Dans le fichier backend/models.js, on ajoute à l'initialisation du modèle User la propriété twoManRuleKey :

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

/* ... */

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

/* ... */

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

/* ... */

Fichier de configuration

On ajoute deux variables dans le fichier de configuration du backend :

NomValeur par défautObligatoireDescription
KEY_STORAGE_URLundefinedOuiURL de SSKS, disponible sur le tableau d'administration Seald
KEY_STORAGE_URL_APP_KEYundefinedOuiClé d'API ssksAppKey, disponible sur le tableau d'administration Seald

Pour savoir où trouver ces valeurs, référez-vous au paragraphe en question dans le guide concernant l'intégration du 2-man-rule.

Point d'API sendChallenge2MR

Ce point d'API fait en une requête les deux éléments décrits dans le guide d'intégration dédié :

  • il génère & stocke si besoin, puis rend la twoManRuleKey associée à un utilisateur de votre application ;
  • il utilise l'API de SSKS pour que l'utilisateur puisse s'authentifier auprès de SSKS.

Dans le fichier backend/routes/account.js, on va ajouter une route POST authentifiée '/sendChallenge2MR'.

Pour interagir avec SSKS on va utiliser node-fetch, il faut donc l'installer :

bash
cd backend
npm install node-fetch

Puis l'importer dans le fichier backend/routes/account.js :

js
import fetch from 'node-fetch'

Enfin, on crée la route /sendChallenge2MR qui va :

  • ordonner à SSKS, si une authentification de l'utilisateur est nécessaire, d'envoyer un email selon le modèle template_id à l'adresse email de l'utilisateur qui contiendra un challenge (valable 6h) que l'utilisateur renverra à SSKS dans la session identifiée par le twoManRuleSessionId ;
  • générer, stocker & rendre la twoManRuleKey à l'utilisateur ;
  • indiquer au frontend si une authentification est nécessaire pour stocker l'identité sur 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
            // template_id: '00000000-0000-0000-0000-000000000000', // ID of the template to use. Templates are created on the dashboard.
            auth_factor: {
              type: 'EM',
              value: user.emailAddress // email address of the user
            }
          })
        }
      )
      // 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

Il est important de donner un numéro de téléphone ou une adresse e-mail valide.

Aussi, dans votre propre implémentation, il est recommandé de définir votre propre modèle d'e-mail sur votre tableau d'administration Seald, puis de dé-commenter le champ template_id dans la requête API et d'y mettre l'ID de votre modèle.

Désormais, nous avons fait toutes les modifications qu'il fallait effectuer sur le backend, passons au frontend.

Modification du frontend

Nous allons dans l'ordre :

  • modifier le client API frontend/src/services/api.ts pour y intégrer ce nouveau point d'API, et supprimer la pré-dérivation du mot de passe ajoutée précédemment, inutile avec le mode de protection en 2-man-rule ;
  • modifier le service Seald frontend/src/services/seald.ts pour modifier les fonctions de création et de récupération d'identité ;
  • ajouter, si demandé par le backend (si mustAuthenticate === true), l'étape d'authentification par email effectuée par SSKS en UI dans le SignIn.tsx et le SignUp.tsx.

Client API

Dans le fichier frontend/src/services/api.ts, on ajoute la route sendChallenge2MR: body => POST('/account/sendChallenge2MR', body) à l'APIClient, puis on ajoute une méthode statique sendChallenge2MR à la classe User :

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

Ensuite, on peut supprimer la pré-dérivation du mot de passe dans les méthodes createAccount et login.

Pour faciliter les futurs appels, on crée une fonction sendChallenge2MR qui est un alias pour la fonction ci-dessus :

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

Service Seald

Configuration

On commence par installer le plugin @seald-io/sdk-plugin-ssks-2mr, supprimer le plugin @seald-io/sdk-plugin-ssks-password et scyrpt :

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

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

Puis, dans le fichier frontend/src/services/seald.ts, on l'importe et l'ajoute au SDK. On importe également User dont on aura besoin par la suite :

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

Création et sauvegarde de l'identité

La sauvegarde de l'identité se fait désormais en deux étapes :

  • la demande d'envoi du challenge ;
  • la sauvegarde de l'identité sur SSKS, avec ou sans challenge selon la valeur de mustAuthenticate.

Pour cela, on modifie la fonction createIdentity qui n'a plus besoin du password comme 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
}

Aussi, immédiatement après son appel dans frontend/src/containers/SignUp.tsx, on demande l'envoi d'un challenge via le point d'API créé ci-dessus :

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

Cette fonction retourne un objet contenant twoManRuleKey, twoManRuleSessionId (à ne pas confondre avec le sessionID de l'authentification du client sur le backend), et mustAuthenticate, envoyés par le backend.

Si mustAuthenticate est true, SSKS déclenche l'envoi d'un challenge par email ou par SMS. Ce challenge est valable 6h qui devra être recopié par l'utilisateur.

Si mustAuthenticate est false, la sauvegarde de l'identité sur SSKS peut se faire sans challenge.

On crée une fonction saveIdentity2MR qui permettra d'enregistrer l'identité sur 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,
    email: emailAddress,
    authFactor: {
      type: 'EM',
      value: emailAddress
    },
    twoManRuleKey,
    userId,
    sessionId: twoManRuleSessionId
  })
}

Une fois cette fonction appelée, l'identité sera sauvegardée sur SSKS.

Récupération de l'identité

Lors de la récupération d'une identité, l'authentification auprès de SSKS est obligatoire.

La récupération de l'identité se fait désormais en deux étapes :

  • l'envoi du challenge ;
  • l'utilisation du challenge pour récupérer l'identité depuis SSKS.

On modifie fonction retrieveIdentity en retrieveIdentity2MR qui ne prend plus password en argument, mais prend comme nouveaux arguments :

  • emailAddress : adresse e-mail de l'utilisateur ;
  • twoManRuleKey : clé stockée par le backend ;
  • twoManRuleSessionId : identifiant de la session créée par le backend pour l'utilisateur auprès de SSKS ;
  • challenge : challenge aléatoire envoyé par e-mail.

Et on change simplement le plugin utilisé pour appeler la fonction sealdSDKInstance.ssks2MR.retrieveIdentity avec ces 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
  })
}

Après avoir appelé cette fonction, l'identité sera récupérée et le SDK sera utilisable.

WARNING

Il est déconseillé de récupérer la même identité avec ssks2MR.retrieveIdentity sur plusieurs appareils en même temps, au même instant exact, par exemple lors de tests automatisés. Veuillez attendre que l'un des appareils ait fini de récupérer l'identité avant de lancer la récupération sur un autre appareil.

Modification de l'interface utilisateur

Il faut désormais appeler ces fonctions :

  • à la création de compte ;
  • à la connexion.

Création de compte SignUp.tsx

On commence par rajouter deux hooks d'état :

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

Puis, on ajoute un composant ChallengeForm qui est un formulaire permettant à l'utilisateur, lorsque mustAuthenticate est true, de taper le challenge et qui appelle handleChallengeSubmit lorsque le formulaire est validé :

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

Ce ChallengeForm sera utilisé à la place du SignupForm si challengeSession est défini :

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

Ensuite, on modifie la fin du handleSignupSubmit pour qu'elle affiche à l'utilisateur le ChallengeForm si mustAuthenticate est true, et pour qu'elle enregistre directement l'identité sinon :

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]
)

Enfin, on crée handleChallengeSubmit qui utilise le challenge complété par l'utilisateur pour sauvegarder son identité sur 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]
)

Ça y est, nous avons mis en œuvre toutes les modifications dans le frontend.

2-man-rule par SMS

Une fois le two-man-rule avec un challenge par email implémenté, il est très facile de passer au challenge par SMS. Il va falloir changer le frontend pour demander un numéro de téléphone à l'utilisateur, puis l'enregistrer dans la base de données coté backend. Nous allons ensuite effectuer des modifications mineures à l'utilisation du plugin @seald-io/sdk-plugin-ssks-2mr.

Modification du modèle User en backend

Dans le fichier backend/models.js, on ajoute à l'initialisation du modèle User la propriété phoneNumber :

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

/* ... */

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

/* ... */

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

/* ... */

Modification du point d'API et du validateur associé

Il faut ajouter un champ phoneNumber dans le validateur de requête :

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

Le champ phoneNumber doit ensuite être récupéré et transmis au modèle :

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

Modification du point d'API sendChallenge2MR

Les modifications ici sont simples. Il faut modifier le auth_factor. Son type passe de 'EM' à 'SMS', et sa value change pour contenir le numéro de téléphone.

Deuxièmement, si vous utilisez un modèle personnalisé, il faut modifier le champ template_id pour y mettre l'ID d'un modèle de SMS (et non plus un modèle d'email) défini sur votre tableau d'administration Seald.

js
router.post('/', validate(createAccountValidator), async (req, res, next) => {
  /* ... */      
    body: JSON.stringify({
      create_user: createUser,
      user_id: user.id,
      // template_id: '00000000-0000-0000-0000-000000000000', // ID of the template to use. Templates are created on the dashboard.
      auth_factor: {
        type: 'SMS',
        value: user.phoneNumber
      }
    })
  /* ... */
}

Modification coté frontend

Les modifications coté frontend sont très similaires à celle du backend. Il va falloir :

  • Ajouter un champ numéro de téléphone dans le SignupForm.
  • Ajouter une clé phoneNumber dans le modèle utilisateur.
  • Modifier le point d'API de création de compte et son appel pour inclure le numéro de téléphone.

Une fois ces trois modifications effectuées, il faut changer les fonctions saveIdentity2MR et retrieveIdentity2MR dans le fichier seald.js. Il faut ajouter un argument phoneNumber à ces fonctions, puis modifier l'argument authFactor lors des appels aux fonctions du plugin. Là encore, le type du facteur d'authentification devient 'SMS', et sa valeur devient le numéro de téléphone.

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

Nous avons pu mettre en œuvre la protection en 2-man-rule complètement, des améliorations peuvent encore être apportées, par exemple au lieu de demander à l'utilisateur de recopier le challenge il est possible de l'inclure dans un magic link vers l'application (attention à ne pas utiliser de variable GET mais un fragment d'URL pour que le serveur ne connaisse pas le challenge).