React NativeでExpo Secure Storeを使う

CategoryReact Native
Published

React Nativeでは安全にデータを保管する方法がありません。保存したものはコードベースで保管されるため、バンドルを解析するとプレーンテキストでアクセスができてしまいます。つまりトークンなど機密情報の保管ができない。一方、プラットフォームは以下のようなセキュアな保管手段を用意しています。

  • iOS: keychain service
  • Android: Secure Shared Prefernces

これらをReact Nativeで扱うにはreact-native-keychain、Expo環境の場合はexpo-secure-storeを使うことが多いようです。前者は生体認証の取扱いやセキュリティレベル・ストレージの暗号タイプも指定できます。その代わりプラットフォームごとにAPIが異なります(共通も有)。両者とも単純な用途ならば大体似たような構文になります。

今回、ExpoプラットフォームではないReact NativeでExpo SDKのライブラリを使用するということを試してみたかったので、expo-secure-storeを利用してみました。

準備

Expoを導入せずExpo SDKのライブラリを使用したい場合は、unimodulesをインストールします。

yarn add react-native-unimodules expo-secure-store

今回利用しているパッケージのバージョンは以下です。

  • react-native: 0.64.1
  • expo-secure-store: 10.1.0
  • react-native-unimodules: 0.13.3

以下、いくつかファイルを編集する必要があります。長いので今回はAndroid版のみ記述します。

android/build.gradle

buildscript {
    ext {
        // update
        buildToolsVersion = "29.0.3"
        minSdkVersion = 21
        compileSdkVersion = 30
        targetSdkVersion = 30

昨今、ビルド時にTask :expo-permissions:compileDebugKotlin FAILEDが表示されることがありますが、そういった場合はAndroid SDK Managerを使用して最新のBuild Toolsに更新すると解決されることがあります。SDK Build ToolsはAndroid/Sdk/build-tools以下にあります。

もしくは、上記エラーならばreact-native-unimodulesのバージョンを下げると大体解決できます。

android/app.build.gradle

apply plugin: "com.android.application"
// add
apply from: '../../node_modules/react-native-unimodules/gradle.groovy'

...

dependencies {
    implementation fileTree(dir: "libs", include: ["*.jar"])
    implementation "com.facebook.react:react-native:+"
    implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"

    // add
    addUnimodulesDependencies()

android/settings.gradle

rootProject.name = 'ex508Sample'
// add
apply from: '../node_modules/react-native-unimodules/gradle.groovy'; includeUnimodulesProjects()

android/app/src/main/java/com//MainApplication.java

package com.ex508sample;

// add (com.myprojectxは自身のパッケージ名に合わせる)
import com.myprojectx.generated.BasePackageList;

import android.app.Application;
import android.content.Context;
import com.facebook.react.PackageList;
import com.facebook.react.ReactApplication;
import com.horcrux.svg.SvgPackage;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.soloader.SoLoader;
import java.lang.reflect.InvocationTargetException;
import java.util.List;

// add
import java.util.Arrays;
import org.unimodules.adapters.react.ModuleRegistryAdapter;
import org.unimodules.adapters.react.ReactModuleRegistryProvider;
import org.unimodules.core.interfaces.SingletonModule;

public class MainApplication extends Application implements ReactApplication {
  // add
  private final ReactModuleRegistryProvider mModuleRegistryProvider =
    new ReactModuleRegistryProvider(new BasePackageList().getPackageList(), null);

  ...

  private final ReactNativeHost mReactNativeHost =
        ...

        @Override
        protected List<ReactPackage> getPackages() {
          @SuppressWarnings("UnnecessaryLocalVariable")
          List<ReactPackage> packages = new PackageList(this).getPackages();

          // add
          List<ReactPackage> unimodules = Arrays.<ReactPackage>asList(
            new ModuleRegistryAdapter(mModuleRegistryProvider)
          );
          packages.addAll(unimodules);
          return packages;
        }

既にプロジェクトをビルドしている場合は、以下のコマンドを実行してgradleをリセットします。

cd android
./gradlew clean

実際の例

フォームからキーと値を保管して、あとから取得できるかを確認できる簡易な例を試します。

result

キーと値を入力する画面をHome、取得する画面をVaultとして、Stackナビゲーションを作成します。

// App.js
import React from 'react';
import {NavigationContainer} from '@react-navigation/native';
import {createStackNavigator} from '@react-navigation/stack';

import Home from './screens/Home';
import Vault from './screens/Vault';

const Stack = createStackNavigator();

export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator initialRouteName="Home">
        <Stack.Screen name="Home" component={Home} />
        <Stack.Screen name="Vault" component={Vault} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

各スクリーンをラップするコンポーネントを作成します。これはコンテナとなる全体のレイアウトです。また、デバイスがSecure Storeが使えるかの判断をisAvailableAsync()で行っています。trueが返れば使用可。

// components/WrapContainer.js
import React, {useState, useEffect} from 'react';
import {StyleSheet, View} from 'react-native';
import * as SecureStore from 'expo-secure-store';
import {Alert} from 'react-native';

const WrapContainer = ({children}) => {
  const [flag, setFlag] = useState();

  useEffect(() => {
    async function check() {
      // trueなら使用できる
      const f = await SecureStore.isAvailableAsync();
      setFlag(f);
    }
    check();
  }, []);

  if (flag === false) {
    Alert.alert('Secure Storeが使用できません');
  }

  return <View style={styles.container}>{children}</View>;
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignContent: 'center',
    margin: 16,
  },
});

export default WrapContainer;

HomeではExpo Secure StoreのsetItemAsync(key, name)でキー・バリューを保存します。それ以外は単なるフォームです。また、レイアウトにはreact-native-elementsを利用していますが、React Nativeデフォルトのコンポーネントを利用しても問題ありません。

// srreens/Home.js
import React, {useState} from 'react';
import {Text, Button, Input} from 'react-native-elements';
import * as SecureStore from 'expo-secure-store';
import WrapContainer from '../components/WrapContainer';

async function save(key, value) {
  await SecureStore.setItemAsync(key, value);
}

export default function Home({navigation}) {
  const [key, onChangeKey] = useState('');
  const [value, onChangeValue] = useState('');

  return (
    <WrapContainer>
      <Text>キーペアを保存します</Text>
      <Input
        clearTextOnFocus
        onChangeText={text => onChangeKey(text)}
        value={key}
        placeholder="key"
      />
      <Input
        clearTextOnFocus
        onChangeText={text => onChangeValue(text)}
        value={value}
        placeholder="value"
      />
      <Button
        title="Save"
        onPress={() => {
          save(key, value);
          onChangeKey('');
          onChangeValue('');
        }}
        disabled={key.longth === 0 || value.length === 0 ? true : false}
      />
      <Button
        title="Vault"
        onPress={() => navigation.navigate('Vault')}
        containerStyle={{marginTop: 10}}
      />
    </WrapContainer>
  );
}

最後はキーから値を取得するVaultスクリーンです。キーを受けつけるフォームを用意し、入力後にEnterを押したらSecure StoreのgetItemAsync(key)で指定したキーの値を取得します。ここはreact-native-keychainと異なります。

また、削除用のフォームも設けています。削除はdeleteItemAsync(key)です。

// srcreens/Vault.js
import React from 'react';
import {Text, Input} from 'react-native-elements';
import {Alert} from 'react-native';
import * as SecureStore from 'expo-secure-store';
import WrapContainer from '../components/WrapContainer';

async function getValueFor(key) {
  if (key.length === 0) {
    return;
  }
  let result = await SecureStore.getItemAsync(key);
  if (result) {
    Alert.alert('あなたの値 \n' + result);
  } else {
    Alert.alert('そのキーは保存されていません');
  }
}

async function removeKey(key) {
  if (key.length === 0) {
    return;
  }
  await SecureStore.deleteItemAsync(key);
  Alert.alert('削除完了');
}

export default function Vault() {
  return (
    <WrapContainer>
      <Text>取得したいキーを入力後、Enterを押してください</Text>
      <Input
        onSubmitEditing={event => {
          getValueFor(event.nativeEvent.text);
        }}
        placeholder="Your Key"
      />
      <Text>削除したいキーを入力後、Enterを押してください</Text>
      <Input
        onSubmitEditing={event => {
          removeKey(event.nativeEvent.text);
        }}
        placeholder="Your Key"
      />
    </WrapContainer>
  );
}

以上です。例外処理などは記述していませんが、単純な構文だけで基本的な実装ができました。実際のアプリケーションではトークンなどの保存で利用することになりそうです。

今回利用した3つのメソッドは、プラットフォームに対応したキーチェーン用のオプションが用意されています。詳しくはドキュメントにて。