React Native Gestionnaire de mots de passe : Initialisation et Déverrouillage

Bonjour à tous dans cet article nous allons mettre en place l'initialisation de l'application ainsi que le déverrouillage. Nous allons aussi voir le fonctionnement de l'encryption des données et créé l'ensemble des fonctions nécessaires pour l'encryption.

Pour tous ce qui concerne l'encryption nous allons utilisé la librairie crypto-js qui contient tous ou presque tous les standards de la crypto.

yarn add crypto-js

Encryption des données

Avant de passer à l'implemantation de l'encryption des donées nous allons voir comment tous cela va fonctionner.

Tous d'abord les données vont être cryptées avec l'algorithme AES qui est un algorithme de chiffrement symétrique. Le principe est assez simple on lui passe les données à cryptées ainsi qu'une clé de chiffrement et en sortie on récupère les données cryptées et inversement on lui passe les données cryptées et la clé et on récupère les données décryptées.

En javascript avec la librairie crypto-js cela donne :

// Encrypt
var ciphertext = CryptoJS.AES.encrypt('my message', 'secret key 123')

// Decrypt
var bytes = CryptoJS.AES.decrypt(ciphertext.toString(), 'secret key 123')
var plaintext = bytes.toString(CryptoJS.enc.Utf8)

console.log(plaintext)

Dans notre application la clé utilisée pour crypter les données sera dérivée du mot de passe principale définit par l'utilisateur. Pour générer la clé à partir du mot de passe nous allons la fonction de dérivation PBKDF2. (Voir PBKDF2 sur wikipédia).

Bien sur cette clé ne seras pas stockée dans l'application et sera générée à chaque fois que l'utilisateur déverrouille l'application.

CryptoHelper

Maintenant que nous avons vu dans les grandes lignes comment va fonctionner l'encryption des données nous allons créer un helper qui contiendra l'ensemble des fonctions nécessaires pour l'encryption des données.

Dans le dossier common créer un fichier nommé CryptoHelper.js :

cd common && touch CryptoHelper.js

Pour commencer nous allons ajouter la méthode GenerateKey :

/*
* @flow
*/

import CryptoJS from 'crypto-js'

export const GenerateKey = (
  password: string,
  salt: string
): CryptoJS.WordArray => {
  const parsedSalt = CryptoJS.enc.Hex.parse(salt)
  const key = CryptoJS.PBKDF2(password, parsedSalt, {
    keySize: 256 / 32,
    iterations: 1000,
  })
  return key
}

Ensuite nous ajoutons la fonction qui permet de vérifier que le mot de passe est correct :

export const IsValidPassword = (
  verificationToken: string,
  password: string,
  salt: string
): boolean => {
  const currentToken = CryptoJS.SHA512(salt + password)
  return currentToken.toString() === verificationToken
}

Lorsqu'on initialisera l'application nous allons générer un salt qui servira pour la génération de la clé et pour le token de vérification, ce salt sera enregistré. Ensuite nous allons aussi générer un token de vérification ce dernier est un hash de la concaténation du salt et du mot de passe ce token sera lui aussi enregistré.

Enfin pour vérifier que le mot de passe saisi par l'utilisateur lors du déverrouillage de l'application est correct on récupère le salt, puis on génère un nouveau token à partir du salt et du mot de passe saisi, si ce token est égal à celui enregistré lors de l'initialisation alors le mot de passe est correct.

Maintenant nous allons ajouter les fonctions Encrypt et Decrypt :

export const Encrypt = (
  data: string,
  key: CryptoJS.WordArray,
  iv: string
): string => {
  const parsedIv = CryptoJS.enc.Hex.parse(iv)
  const encrypted = CryptoJS.AES.encrypt(data, key, {
    iv: parsedIv,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7,
  })
  return encrypted.ciphertext.toString()
}

export const Decrypt = (data: string, key: CryptoJS.WordArray, iv: string) => {
  const parsedIv = CryptoJS.enc.Hex.parse(iv)
  const cipherStuff = CryptoJS.lib.CipherParams.create({
    key,
    iv: parsedIv,
    ciphertext: CryptoJS.enc.Hex.parse(data),
  })
  return CryptoJS.AES.decrypt(cipherStuff, key, {
    iv: parsedIv,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7,
  }).toString(CryptoJS.enc.Utf8)
}

Ici comme expliqué plus haut pour crypter et décrypter on passe les données, la clé et on utilise crypto-js.

La différence avec l'exemple c'est qu'ici en plus on passe un paramètre nommée iv. L'iv ou vecteur d'initialisation est un bloc de bits combiné avec le premier bloc de données lors d'une opération de chiffrement (Voir iv sur wikipédia). Cet iv sera lui aussi généré et stocké à l'initialisation de l'application. Nous faisons ça car lorsque nous le passons pas manuellement comme dans le premier exemple celui-ci est généré aléatoirement par crypto-js cependant puisse que l'application permet de sauvegarder les données si nous stockons pas l'iv lorsqu'un utilisateur récupère ces données sur un autre téléphone alors l'iv n'est plus le meme et les données ne peuvent pas être décrypté.

Pour terminer nous allons ajouter la méthode IntializeData qui génère et retourne tous les paramètres que nous avons vu jusqu'a présent.

type CryptoParams = {
  salt: string,
  iv: string,
  key: CryptoJS.WordArray,
  verificationToken: string,
}

export const InitializeData = (password: string): CryptoParams => {
  const salt = CryptoJS.lib.WordArray.random(128 / 8)
  const iv = CryptoJS.lib.WordArray.random(128 / 8)
  const key = GenerateKey(password, salt.toString())
  const verificationToken = CryptoJS.SHA512(salt.toString() + password)

  return {
    salt: salt.toString(),
    iv: iv.toString(),
    key,
    verificationToken: verificationToken.toString(),
  }
}

Initialisation de l'application

Nous allons passer à l'initialisation de l'application c'est à dire ajouter les actions, les reducers et modifier l'écran.

On ajoute d'abord les types d'action dans le fichier actions/types.js :

...
/*
**************
* Initialization
**************
*/

export type InitializationSuccessAction = {
  type: 'INITIALIZATION_SUCCESS',
  salt: string,
  iv: string,
  verificationToken: string,
};

export type InitializationFailAction = {
  type: 'INITIALIZATION_FAIL',
  error: string,
};

export type Action =
  /** *** Settings **** */
  | SetPasswordLengthAction
  | SetAutoGenerationAction
  /** *** Initialization **** */
  | InitializationSuccessAction
  | InitializationFailAction;

Ensuite créez le fichier initialization.js dans le dossier actions et ajoutez le code suivant :

/*
* @flow
*/

import type {
  ThunkAction,
  Dispatch,
  InitializationFailAction,
  InitializationSuccessAction,
} from './types'
import strings from '../locales/strings'
import { InitializeData } from '../common/CryptoHelper'

/*
*** Actions ***
*/

const initializeApplication = (
  password: string,
  confirmation: string,
  resetRoute: () => void
): ThunkAction => (dispatch: Dispatch) => {
  if (password.length === 0 || confirmation.length === 0) {
    dispatch(initializationFail(strings.passwordLenghtError))
  } else if (password !== confirmation) {
    dispatch(initializationFail(strings.confirmationError))
  } else {
    const data = InitializeData(password)
    dispatch(initializationSuccess(data.salt, data.iv, data.verificationToken))
    resetRoute()
  }
}
export default initializeApplication

/*
*** Actions Creator ***
*/

const initializationSuccess = (
  salt: string,
  iv: string,
  verificationToken: string
): InitializationSuccessAction => ({
  type: 'INITIALIZATION_SUCCESS',
  salt,
  iv,
  verificationToken,
})
const initializationFail = (error: string): InitializationFailAction => ({
  type: 'INITIALIZATION_FAIL',
  error,
})

Puis ajoutez les ressources :

fr.js

confirmationError:'Votre mot de passe et la confirmation ne sont pas identiques',
passwordLenghtError:'Vous devez saisir le mot de passe et la confirmation',

en.js

confirmationError:'Your password and confirmation are not the same',
passwordLenghtError:'You need to define a password and the confirmation',

strings.js

confirmationError:I18n.t('confirmationError'),
passwordLenghtError:I18n.t('passwordLenghtError'),

Nous allons ajouter le reducer user qui contiendra les paramètres que nous avons générés mais avant définissons le type du reducer dans le fichier reducers/types.js :

...
export type UserState = {
  +salt:string,
  +verificationToken:string,
  +iv:string,
  +appInitialized:boolean,
  +error:string
}

export type ReduxState = {
  +settings: SettingsState,
  +user:UserState
};

Puis ajoutez le fichier user.js :

/*
* @flow
*/

import type { Action } from '../actions/types'
import type { UserState } from './types'

const initialState: UserState = {
  appInitialized: false,
  iv: '',
  salt: '',
  verificationToken: '',
  error: '',
}

const userState = (
  state: UserState = initialState,
  action: Action
): UserState => {
  switch (action.type) {
    case 'INITIALIZATION_SUCCESS':
      return {
        ...state,
        appInitialized: true,
        iv: action.iv,
        salt: action.salt,
        verificationToken: action.verificationToken,
        error: '',
      }
    case 'INITIALIZATION_FAIL':
      return { ...state, error: action.error }
    default:
      return state
  }
}

export default userState

Pour finir ajoutez le reducer au rootReducer dans le fichier index.js :

/*
* @flow
*/

import { combineReducers } from 'redux'
import settings from './settings'
import user from './user'

const rootReducer = combineReducers({
  settings,
  user,
})

export default rootReducer

Il ne nous reste plus qu'à modifier l'écran d'intialisation pour intégrer redux. Avant cela nous allons modifier la navigation au sein de l'application actuellement l'application s'ouvre sur l'écran d'initialisation cependant une fois que l'application est initialisée nous voulons que l'écran par défaut soit celui qui permet de déverrouiller l'application. Pour palier à cela nous allons définir dans le stackNavigator que l'écran initiale est est celui de déverrouillage et dans celui-ci nous allons controller que l'application à été initialisée si ce n'est pas le cas alors on renverra l'utilisateur sur l'écran d'initialisation.

Modifier le stackNavigator de la manière suivante :

StackNavigator(
  {
   ...routes
  },
  {
    initialRouteName: 'Unlock',
  },

Ensuite on va intégrer Redux à l'écran de déverrouillage ajoutez d'abord les imports suivants :

import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { NavigationActions } from 'react-navigation'

Ensuite créez un type Props et ajoutez le au composant :

...
type Props = {
  navigation: Object,
  appInitialized: boolean,
};

class UnlockScreen extends Component<void, Props, State> {
...

Puis connectez redux à l'écran :

function mapStateToProps(state: ReduxState) {
  return {
    appInitialized: state.user.appInitialized,
  }
}
export default connect(mapStateToProps, dispatch => ({
  actions: bindActionCreators({}, dispatch),
}))(UnlockScreen)

Pour le moment puisque nous avons pas créé les actions pour le déverrouillage nous passons un objet vide dans la fonction binActionCreators.

Enfin dans le composant rajoutez :

componentWillMount() {
  if (!this.props.appInitialized) {
    const resetAction = NavigationActions.reset({
      index: 0,
      actions: [NavigationActions.navigate({ routeName: 'Setup' })],
    });
    this.props.navigation.dispatch(resetAction);
  }
}

Ici on vérifie que l'application a été initialisé sinon on réinitialise la navigation sur l'écran d'initialisation.

Nous allons maintenant connecter l'écran Setup.js à redux, ajoutez les imports suivants et le type Props :

...
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { NavigationActions } from 'react-navigation';
import type { ReduxState } from '../reducers/types';
import initializeApplication from '../actions/initialization';
...
type Props = {
  navigation: Object,
  error: string,
  actions: Object,
};
class SetupScreen extends Component<void, Props, State> {
...

Ensuite on connect redux :

function mapStateToProps(state: ReduxState) {
  return {
    error: state.user.error,
  }
}
export default connect(mapStateToProps, dispatch => ({
  actions: bindActionCreators({ initializeApplication }, dispatch),
}))(SetupScreen)

Dans la méthode render nous allons remplacer le message "Ce texte sera remplacer lorsqu'on ajoutera redux" par la propriété error :

render() {
    return (
      <ScrollView contentContainerStyle={styles.container}>
        ...
        <Text style={[styles.instruction, { color: DELETE_COLOR }]}>
          {this.props.error}
        </Text>
        ...
      </ScrollView>
    );
  }

Et enfin modifiez la fonction submit de cette manière :

submit() {
  const { password, confirmation } = this.state;
  const resetAction = NavigationActions.reset({
    index: 0,
    actions: [NavigationActions.navigate({ routeName: 'Unlock' })],
  });
  const reset = () => this.props.navigation.dispatch(resetAction);
  this.props.actions.initializeApplication(password, confirmation, reset);
}

Dans cette fonction on commence par récupérer le mot de passe et la confirmation du state, ensuite on définit l'action reset qui permet de rediriger vers l'écran de déverrouillage une fois l'initialisation terminée. Enfin on déclenche l'action d'initialisation.

Déverrouillage de l'application

Passons maintenant au déverrouillage de l'application. Lors du déverrouillage de l'application nous allons aussi devoir décrypter les données des mots de passe. Dans notre application nous ne voulons pas que certaines données soit enregistrés c'est le cas pour la clé qui permet de crypter ainsi que les mots de passe en clair. Nous allons donc devoir séparer ces données dans deux reducers différents et ensuite indiquer à redux-persist que certains reducers ne doivent pas être persisté et réhydraté.

Nous allons donc créer deux nouveaux reducers et leurs types, le premier sera data qui contiendra la liste des mots de passe non cryptés ainsi que la clé. Le second sera cryptedData qui contiendra la liste des mots de passe cryptés.

Dans le reducers data la liste des mots de passe sera normalisé (Voir documentation redux), étant donné que les données normalisées on tous le temps la meme structure nous allons créer un type NormalizedState dans le dossier types :

export type NormalizedState = {
  byId: Object,
  allIds: Array<string>,
}

Ensuite nous allons ajouter les types pour nos deux reducers dans reducers/types.js :

...
export type DataState = {
  +key: CryptoJS.WordArray,
  +passwords: NormalizedState,
  +error:string,
};

export type CryptedDataState = {
  +passwords: string,
};

export type ReduxState = {
  +settings: SettingsState,
  +user: UserState,
  +data: DataState,
  +cryptedData: CryptedDataState,
};

Puis nous créons le reducer cryptedData, dans le dossier reducers ajoutez cryptedData.js :

/*
* @flow
*/

import type { Action } from '../actions/types'
import type { CryptedDataState } from './types'

const initialState: CryptedDataState = {
  passwords: '',
}

const cryptedDataState = (
  state: CryptedDataState = initialState,
  action: Action
): CryptedDataState => {
  switch (action.type) {
    case 'UPDATE_CRYPTED_PASSWORDS':
      return {
        ...state,
        passwords: action.cryptedPassword,
      }
    default:
      return state
  }
}

export default cryptedDataState

Ensuite nous ajoutons le type de l'action UPDATECRYPTEDPASSWORD dans le fichier types.js du dossier actions :

...
/*
**************
* Passwords
**************
*/

export type UpdateCryptedPasswordsAction = {
  type: 'UPDATE_CRYPTED_PASSWORDS',
  cryptedPassword: string,
};

export type Action =
  /** *** Settings **** */
  | SetPasswordLengthAction
  | SetAutoGenerationAction
  /** *** Initialization **** */
  | InitializationSuccessAction
  | InitializationFailAction
  /** *** Passwords **** */
  | UpdateCryptedPasswordsAction;

Mainenant nous allons créer le reducer data, dans le dossier reducers ajoutez data.js :

/*
* @flow
*/

import type { Action } from '../actions/types'
import type { DataState } from './types'

const initialState: DataState = {
  passwords: { allIds: [], byId: {} },
  key: '',
  error: '',
}

const dataState = (
  state: DataState = initialState,
  action: Action
): DataState => {
  switch (action.type) {
    case 'UNLOCK_APP':
      return {
        ...state,
        passwords: action.passwords,
        key: action.key,
        error: '',
      }
    case 'UNLOCK_APP_FAIL':
      return {
        ...state,
        error: action.error,
      }
    default:
      return state
  }
}

export default dataState

Ensuite nous ajoutons le type des actions dans actions/types :

...
export type UnlockAppAction = {
  type: 'UNLOCK_APP',
  passwords: NormalizedState,
  key: CryptoJS.WordArray,
};

export type UnlockAppFailAction = {
  type: 'UNLOCK_APP_FAIL',
  error: string,
};

export type Action =
  /** *** Settings **** */
  | SetPasswordLengthAction
  | SetAutoGenerationAction
  /** *** Initialization **** */
  | InitializationSuccessAction
  | InitializationFailAction
  /** *** Passwords **** */
  | UpdateCryptedPasswordsAction
  | UnlockAppAction
  | UnlockAppFailAction;

Enfin on ajoute nos deux reducers au root reducer :

import cryptedData from './cryptedData'
import data from './data'

const rootReducer = combineReducers({
  settings,
  user,
  cryptedData,
  data,
})

Revenons sur les types d'actions que nous avons créés tout d'abord UpdateCryptedPasswordsAction cette action sera utilisée dès lors qu'on voudras mettre à jour la liste des mots de passe cryptés (ajout/suppression/modification).

Ensuite UnlockAppAction cette action est utilisée lorsque nous allons déverrouiller l'application elle va permettre de mettre à jour la liste complète des mots de passe qui viennent d'être décryptés ainsi que la clé pour crypter les données.

Pour finaliser le déverrouillage de l'application voici ce qu'il nous reste à faire : premièrement modifier l'action d'initialisation des données pour y ajouter l'encryption de la liste des mots passe normalisés ({ allIds: [], byId: {} },) vide. Ensuite il faut créer l'action qui permet de déverrouiller l'application, connecter l'écran de déverrouillage à redux et enfin retirer le reducer data des données sauvegarder par redux-persist.

Lors de l'initialisation de l'application une fois que les données (salt,token de vérification ...) seront initialisées nous allons crypté l'objet { allIds: [], byId: {} } et l'ajouter au state en dispatchant l'action UpdateCryptedPasswordsAction.

Commencez par rajouter l'import du type UpdateCryptedPasswordsAction ainsi que la fonction Encrypt dans le fichier actions/initialize :

import type {
  ThunkAction,
  Dispatch,
  InitializationFailAction,
  InitializationSuccessAction,
  UpdateCryptedPasswordsAction,
} from './types';
import strings from '../locales/strings';
import { InitializeData, Encrypt } from '../common/CryptoHelper';‍
Puis ajoutez l'action creator suivant :

const updateCryptedPasswords = (cryptedPassword: strings) => ({
  type: 'UPDATE_CRYPTED_PASSWORDS',
  cryptedPassword,
});

Enfin remplacez la fonction initializeApplication par celle-ci :

const initializeApplication = (
  password: string,
  confirmation: string,
  resetRoute: () => void
): ThunkAction => (dispatch: Dispatch) => {
  if (password.length === 0 || confirmation.length === 0) {
    dispatch(initializationFail(strings.passwordLenghtError))
  } else if (password !== confirmation) {
    dispatch(initializationFail(strings.confirmationError))
  } else {
    const data = InitializeData(password)
    const emptyPassword = JSON.stringify({ allIds: [], byId: {} })
    const cryptedEmptyPasswords = Encrypt(emptyPassword, data.key, data.iv)
    dispatch(initializationSuccess(data.salt, data.iv, data.verificationToken))
    dispatch(updateCryptedPasswords(cryptedEmptyPasswords))
    resetRoute()
  }
}

Maintenant nous allons créer l'action qui permet de dévérouiller l'application et connecter l'écran avec redux :

Dans le dossier actions créer un fichier unlock.js et ajoutez le code suivant :

/*
* @flow
*/

import type {
  ThunkAction,
  Dispatch,
  UnlockAppAction,
  UnlockAppFailAction,
} from './types'
import type { NormalizedState } from '../types/NormalizedState'
import strings from '../locales/strings'
import { IsValidPassword, Decrypt, GenerateKey } from '../common/CryptoHelper'

/*
*** Actions ***
*/

const unlockApp = (
  password: string,
  verificationToken: string,
  salt: string,
  iv: string,
  cryptedPasswords: string,
  resetRoute: () => void
): ThunkAction => (dispatch: Dispatch) => {
  if (IsValidPassword(verificationToken, password, salt)) {
    const key = GenerateKey(password, salt)
    const passwords = Decrypt(cryptedPasswords, key, iv)
    dispatch(unlockApplication(key, JSON.parse(passwords)))
    resetRoute()
  } else {
    dispatch(unlockAppFail(strings.invalid_password))
  }
}
export default unlockApp

/*
*** Actions Creator ***
*/

const unlockApplication = (
  key: strings,
  passwords: NormalizedState
): UnlockAppAction => ({
  type: 'UNLOCK_APP',
  key,
  passwords,
})

const unlockAppFail = (error: string): UnlockAppFailAction => ({
  type: 'UNLOCK_APP_FAIL',
  error,
})

Puis ajoutez la ressource :

fr.js

invalid_password: 'Votre mot de passe est invalide. Réessayer',

en.js

invalid_password: 'Your password is invalid. Try again',

strings.js

invalid_password: I18n.t('invalid_password'),

Ensuite dans l'écran Unlock.js importez l'action unlockApp et modifiez le type Props :

import unlockApp from '../actions/unlock'
type Props = {
  navigation: Object,
  actions: Object,
  appInitialized: boolean,
  cryptedPasswords: string,
  verificationToken: string,
  salt: string,
  iv: string,
  error: string,
}

Puis il faut rajouter les nouvelles propriétés dans mapStateToProps et ajouter l'action dans bindActionCreators :

function mapStateToProps(state: ReduxState) {
  return {
    appInitialized: state.user.appInitialized,
    cryptedPasswords: state.cryptedData.passwords,
    verificationToken: state.user.verificationToken,
    salt: state.user.salt,
    iv: state.user.iv,
    error: state.data.error,
  }
}
export default connect(mapStateToProps, dispatch => ({
  actions: bindActionCreators({ unlockApp }, dispatch),
}))(UnlockScreen)

Enfin il ne nous reste plus qu'a remplacer le texte "Ce texte sera remplacer lorsqu'on ajoutera redux" par la propriété error et modifier la fonction submit:

...
submit() {
  const resetAction = NavigationActions.reset({
    index: 0,
    actions: [NavigationActions.navigate({ routeName: 'App' })],
  });
  const reset = () => this.props.navigation.dispatch(resetAction);
  this.props.actions.unlockApp(
    this.state.password,
    this.props.verificationToken,
    this.props.salt,
    this.props.iv,
    this.props.cryptedPasswords,
    reset,
  );
}

render() {
    return (
      <ScrollView contentContainerStyle={styles.container}>
        ...
        <Text style={styles.error}> {this.props.error}</Text>
        ...
      </ScrollView>
    );
  }
...

Pour terminer cet article nous allons exclure le reducer data des données que redux-persist doit enregistrer. Dans le fichier configureStore.js il suffit de rajouter la liste des reducers que l'on souhaite exclure dans l'objet config comme ce-ci :

const config = {
  key: 'root', // key is required
  storage, // storage is now required
  blacklist: ['data'],
}

Attention si vous avez déjà initialisé et déverrouillé l'application avant d'avoir exclut le reducer les données on été enregistrées vous allez les supprimer soit avec AsyncStorage.clear ou depuis les réglages de votre smartphone/émulateur pour que la modification soit prise en compte.

Nous en avons maintenant terminé avec l'initialisation et le déverrouillage de l'application dans le prochain article nous passerons à la gestion des mots de passe.

Vous pouvez retrouver le code ici.