Android Keystore で鍵生成・暗号化/復号処理を行う
はじめに
Androidアプリの開発に携わっているエンジニアであれば、誰でも一度は「Android Keystore」という言葉を目にしたことがあるかと思います。しかし、いざ「Android Keystore って何?」と聞かれると、回答に詰まってしまう方も多いのではないでしょうか。Androidの開発者向けサイトである「Android Developers」でも以下のページに解説がありますが、初見ではなかなか理解しにくい個所もあるように思います。
そこで本記事では、「Android Keystore」とは何か、その要点をまとめると共に、どのように利用すればよいかをサンプルコードを交えて解説していこうと思います。本記事の内容を参考に「Android Keystore」に対する理解を深め、よりセキュアなAndroidアプリの開発に役立てていただければ幸いです。
モバイルアプリにおけるデータ保護の必要性
Androidアプリに限らず、モバイルアプリでは、ユーザが入力した情報やセッションID(アクセストークン)等のデータをモバイルデバイス内のアプリ専用ストレージに保存するのが一般的です。"不要なデータは保存しない"というのがセキュアコーディングの原則ですが、アプリの仕様やユーザビリティを考慮すると、どうしても重要なデータを保存せざるを得ないケースが出てきてしまうものです。
「保存しているのはそのアプリ専用のストレージ(←他のアプリからはアクセスできない)なんだし、容量を圧迫しない範囲でデータを好き勝手に保存しても問題ないのでは?」と考える方もいらっしゃるかもしれませんが、モバイルデバイスには以下の記事に記載したようにデバイス自体を悪意のある第三者に窃取・拾得されるリスクがあります。
仮に、悪意のある第三者の手にデバイスが渡ってしまった場合、アプリ専用のストレージにもアクセスされ、大事なデータを奪われてしまう恐れがあります。そのため、アプリ専用のストレージに保存する場合であっても、重要データは平文のままではなく、暗号化した上で保存しておくほうが安全です。
ただ、暗号化しておけば何でも良い、というわけではありません。アプリのソースコード等にハードコーディングされている暗号鍵を利用してデータを暗号化しているような場合、アプリをリバースエンジニアリングすることで容易に暗号鍵を取り出し、データを復号できてしまいます。
では、どのような暗号鍵を利用してデータを暗号化すれば良いでしょうか。
そこで役にたつのが「Android Keystore」です。
Android Keystore の概要
「Android Keystore」は、デバイス内で安全に暗号鍵を生成・管理するためにAndroidシステムが提供している機能です。デバイス内のアプリは「Android Keystore プロバイダ」を介して、そこに保管されている暗号鍵を利用し、データの暗号化/復号処理を行うことができます。主要な特性を記載すると、以下のようになるかと思います。
- アプリごとに異なる暗号鍵を生成することができる。
- 暗号鍵の使用に際してユーザー認証を要求したり、特定の暗号化モードでのみ暗号鍵を使用するよう制限したりするなど、鍵の使用方法や使用期間を制限することができる。
- 暗号鍵はAndroidデバイス内の「コンテナ」と呼ばれる安全な領域内で保管され、デバイスの外部に持ち出すことはできない。
上記の通り、アプリごとに異なる暗号鍵を生成することができるため、ハードコーディング等による暗号鍵の管理は必要なくなります(もちろん、同じアプリでもデバイスが異なれば鍵も異なる)。また、暗号鍵の使用方法や使用期間を制限することができるため、アプリに求められるセキュリティ強度に従って柔軟に暗号鍵の仕様を変えることが可能です。加えて、暗号鍵を保管する「コンテナ」としてTEE(Trusted Execution Environment)やSE(Secure Element)、StrongBoxといったセキュアハードウェアを利用することで、暗号鍵の直接的な操作をアプリのプロセスから分離し、仮にアプリプロセスに不正侵入された場合でも、暗号鍵をデバイス外に抜き出すことができないよう設計されています。
なおOWASPでも、暗号鍵の保管方法としてはこの「Android Keystore」のセキュアハードウェアを利用した保管方法が最も安全であると記されています。
以上が「Android Keystore」の簡単な概要ですが、文章で記載してもなかなかピンと来ないかもしれませんね…。百聞は一見に如かず、ということで、以下ではサンプルコードを交えて「Android Keystore」の利用方法を解説していきたいと思います。
Android Keystore の利用方法
ここからは「Android Keystore プロバイダ」を利用してAES暗号鍵を生成し、その鍵で文字列の暗号化/復号処理を行うサンプルコードを記載していきたいと思います。
まずは、サンプルコードの全文を記載し、後続で各処理の詳細を見ていきます。
全体の流れとしては、主に以下の処理が行われています。
- アプリの起動時にKeyStoreインスタンスをロードする。
- データの暗号化/復号に利用するAES鍵を生成する。
- 入力された文字列を暗号化する。
- 暗号化された文字列を復号する。
- 最後に、入力された文字列・暗号化された文字列・復号した文字列をログに出力する。
なお、サンプルコードでは例外処理を簡略化しています。このコードを参考に暗号化/復号処理を実装する際は、例外の種類に応じて必要なコーディングを行うと良いでしょう。
package sk.sampleapp.androidkeystoresampleapp;
import android.app.Activity;
import android.os.Bundle;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import android.util.Log;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.security.KeyStore;
import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.CipherOutputStream;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
public class MainActivity extends Activity {
private static final String TAG = "KeyStoreSample";
private static final String KEY_PROVIDER = "AndroidKeyStore";
private static final String KEY_ALIAS = "SampleKeyAlias";
private static final String IV = "ABCDEFGHIJKLMNOP";
private static final String ALGORITHM = "AES/CBC/PKCS7Padding";
private KeyStore keyStore = null;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
prepareKeyStore();
createAESKey();
String plainText = "ここに入力された文字列を暗号化・復号します";
String encryptedText = encrypt(plainText);
String decryptedText = decrypt(encryptedText);
Log.d(TAG,"plainText ="+plainText);
Log.d(TAG,"encryptedText="+encryptedText);
Log.d(TAG,"decryptedText="+decryptedText);
}
/**
* KeyStoreのインスタンスをロードする
*/
private void prepareKeyStore() {
try {
keyStore = KeyStore.getInstance(KEY_PROVIDER);
keyStore.load(null);
} catch (Exception e) {
Log.e(TAG, e.toString());
}
}
/**
* データの暗号化・復号に利用するAES鍵を生成する
*/
private void createAESKey() {
try {
if (!keyStore.containsAlias(KEY_ALIAS)) {
KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEY_PROVIDER);
keyGenerator.init(new KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
.setRandomizedEncryptionRequired(false)
.build());
keyGenerator.generateKey();
}
} catch (Exception e) {
Log.e(TAG, e.toString());
}
}
/**
* 受け取った文字列をAES鍵で暗号化して返す
*
* @param plainText
* @return 暗号化した文字列
*/
private String encrypt(String plainText) {
try {
SecretKey secretKey = (SecretKey)keyStore.getKey(KEY_ALIAS,null);
Cipher cipher = Cipher.getInstance(ALGORITHM);
IvParameterSpec ivParameterSpec = new IvParameterSpec(IV.getBytes());
cipher.init(Cipher.ENCRYPT_MODE,secretKey,ivParameterSpec);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
CipherOutputStream cipherOutputStream = new CipherOutputStream(outputStream,cipher);
cipherOutputStream.write(plainText.getBytes("UTF-8"));
cipherOutputStream.close();
return Base64.encodeToString(outputStream.toByteArray(),Base64.NO_WRAP);
} catch (Exception e) {
Log.e(TAG, e.toString());
}
return null;
}
/**
* 受け取った文字列をAES鍵で復号して返す
*
* @param encryptedText
* @return 復号した文字列
*/
private String decrypt(String encryptedText) {
try {
SecretKey secretKey = (SecretKey)keyStore.getKey(KEY_ALIAS,null);
Cipher cipher = Cipher.getInstance(ALGORITHM);
IvParameterSpec ivParameterSpec = new IvParameterSpec(IV.getBytes());
cipher.init(Cipher.DECRYPT_MODE,secretKey,ivParameterSpec);
CipherInputStream cipherInputStream = new CipherInputStream(new ByteArrayInputStream(Base64.decode(encryptedText,Base64.NO_WRAP)),cipher);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
int buffer;
while ((buffer = cipherInputStream.read())!= -1) {
byteArrayOutputStream.write(buffer);
}
byteArrayOutputStream.close();
return byteArrayOutputStream.toString("UTF-8");
} catch (Exception e) {
Log.e(TAG, e.toString());
}
return null;
}
}
KeyStore インスタンスをロードする
まず「Android Keystore」のインスタンスをロードします。このとき、インスタンスを取得する際の引数(以下の"KEY_PROVIDER")には、「AndroidKeyStore」を渡さなければなりません。
private void prepareKeyStore() {
try {
keyStore = KeyStore.getInstance(KEY_PROVIDER);
keyStore.load(null);
} catch (Exception e) {
Log.e(TAG, e.toString());
}
}
データの暗号化・復号に利用するAES鍵を生成する
KeyStore内に作成済の暗号鍵がない場合は、KeyGeneratorを利用して新たなAES鍵を生成します。
今回のサンプルコードでは、ブロックモードとして「CBC」を、パディングには「PKCS7」を利用しています。「CBC」を利用する場合は初期化ベクトルが必要になりますが、今回は簡単のため固定値(IV = "ABCDEFGHIJKLMNOP")を利用するので、setRandomizedEncryptionRequired()にfalseを渡しています。
なお、"KEY_ALIAS"は、暗号鍵を識別するためのエイリアスです。任意の文字列で構いません。
private void createAESKey() {
try {
if (!keyStore.containsAlias(KEY_ALIAS)) {
KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEY_PROVIDER);
keyGenerator.init(new KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
.setRandomizedEncryptionRequired(false)
.build());
keyGenerator.generateKey();
}
} catch (Exception e) {
Log.e(TAG, e.toString());
}
}
KeyGenParameterSpec.Builderには、上記に加えて、より暗号鍵を安全に利用することにつながる設定がいくつかあります。ここではその一例をご紹介しますが、利用する上での制限事項等、詳細は以下のリファレンスをご参照ください。
-
setIsStrongBoxBacked(true)
Android 9.0 (API level 28)以降のデバイスに付属するハードウェア格納型キーストア「StrongBox」を利用して暗号鍵を保管します。「StrongBox」は、従来型のキーストアと比較して若干処理が遅くはなるものの、物理攻撃やサイドチャネル攻撃に対するセキュリティ保証に優れているとされています。アプリのリソース効率よりも高いセキュリティ保証を優先したい場合、かつStrongBoxが利用可能なデバイスでは、この設定の有効化を検討する価値がありそうです。 -
setUnlockedDeviceRequired(true)
こちらもAndroid 9.0 (API level 28)以降のデバイスで利用可能な設定です。この設定が有効な場合、デバイスのロックが解除されていない状態ではデータを復号できないよう制限がかかります(暗号化はロックされている状態でも可能)。 -
setUserAuthenticationRequired(true)
こちらはAndroid 6.0 (API level 23)以降のデバイスで利用可能な設定です。この設定が有効な場合、ユーザ認証に成功しない限り暗号化処理を実施できないよう制限をかけることができます。 -
setUserAuthenticationValidityDurationSeconds(seconds), setUserAuthenticationParameters (timeout, type)
前者はAndroid 11.0 (API level 30)でDeprecatedとなり、それ以降のバージョンでは後者のメソッドの利用が推奨されるようになりました。この設定で指定した秒数内であれば、認証済みのユーザに再度認証画面を表示しないようにできます。
入力された文字列を暗号化する
上記で生成したAES鍵を利用して、文字列を暗号化します。ここでcipherオブジェクトを取得する際の引数(以下の"ALGORITHM")には、AES鍵の生成時に指定したブロックモードやパディングを渡します("AES/CBC/PKCS7Padding")。
private String encrypt(String plainText) {
try {
SecretKey secretKey = (SecretKey)keyStore.getKey(KEY_ALIAS,null);
Cipher cipher = Cipher.getInstance(ALGORITHM);
IvParameterSpec ivParameterSpec = new IvParameterSpec(IV.getBytes());
cipher.init(Cipher.ENCRYPT_MODE,secretKey,ivParameterSpec);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
CipherOutputStream cipherOutputStream = new CipherOutputStream(outputStream,cipher);
cipherOutputStream.write(plainText.getBytes("UTF-8"));
cipherOutputStream.close();
return Base64.encodeToString(outputStream.toByteArray(),Base64.NO_WRAP);
} catch (Exception e) {
Log.e(TAG, e.toString());
}
return null;
}
暗号化された文字列を復号する
続いて、AES鍵を利用して、上記で暗号化された文字列を復号します。暗号化時と同様、cipherオブジェクトを取得する際の引数には、AES鍵の生成時に指定したブロックモードやパディングを渡します。
private String decrypt(String encryptedText) {
try {
SecretKey secretKey = (SecretKey)keyStore.getKey(KEY_ALIAS,null);
Cipher cipher = Cipher.getInstance(ALGORITHM);
IvParameterSpec ivParameterSpec = new IvParameterSpec(IV.getBytes());
cipher.init(Cipher.DECRYPT_MODE,secretKey,ivParameterSpec);
CipherInputStream cipherInputStream = new CipherInputStream(new ByteArrayInputStream(Base64.decode(encryptedText,Base64.NO_WRAP)),cipher);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
int buffer;
while ((buffer = cipherInputStream.read())!= -1) {
byteArrayOutputStream.write(buffer);
}
byteArrayOutputStream.close();
return byteArrayOutputStream.toString("UTF-8");
} catch (Exception e) {
Log.e(TAG, e.toString());
}
return null;
}
入力された文字列・暗号化された文字列・復号した文字列をログに出力する
最後に、入力された文字列・暗号化された文字列・復号した文字列をログに出力します。
Log.d(TAG,"plainText ="+plainText);
Log.d(TAG,"encryptedText="+encryptedText);
Log.d(TAG,"decryptedText="+decryptedText);
出力結果は、以下のようになります。"plainText"の文字列が暗号化されたうえで"encryptedText"に格納され、それが復号されると元の文字列に戻っていることが分かります。
D/KeyStoreSample: plainText =ここに入力された文字列を暗号化・復号します
D/KeyStoreSample: encryptedText=uZtPky6YGrJpHy9anoykS0lxlR/gFiwaDVnxI9Tse1PFDx39/qUo0mYOsjtBgUgRINQNw7CVAOyw2X3dOC9evA==
D/KeyStoreSample: decryptedText=ここに入力された文字列を暗号化・復号します
利用上の留意点
ここまで、「Android Keystore」の概要やサンプルコードを交えた利用方法について解説してきました。文章にすると難しく感じるかもしれませんが、サンプルコードを見れば、比較的シンプルなコードで利用できるということに気づいていただけかと思います。
最後にこの項では、「Android Keystore」を利用する上で知っておくと役に立つ(かもしれない)こととして以下3点を記載していきます。
OSバージョンやデバイスによって利用できる機能が異なる
「Android Keystore」は、OSのバージョンアップに伴って進化を遂げてきました。「Android Keystore プロバイダ」はAndroid 4.3 (API level 18)で初めて利用できるようになったものの、当初は利用できる暗号鍵は非対象暗号のみで、共通鍵暗号を利用できるようになったのはAndroid 6.0 (API level 23)からです。また、前述の「TEE」や「SE」は Android 7.0 (API level 24)以降、「StrongBox」もAndroid 9.0 (API level 28)以降の対応デバイスのみで利用可能となっています。
このように、アプリが動作するデバイスのOSバージョンやセキュアハードウェアの有無によって、「Android Keystore」で利用できる機能に差異があります。このため、「Android Keystore」を利用するコードを実装する際には、アプリが動作保証するOSバージョンや、デバイスにおけるセキュアハードウェアの利用可否等を考慮して処理を分岐させる必要があるかもしれません。
「EncryptedSharedPreferences」の利用も検討する
「EncryptedSharedPreferences」は、SharedPreferencesクラスをラップしたクラスで、これを利用することでSharedPreferencesファイルに保存するKeyとValueを手軽に暗号化することが可能です。(暗号化に利用する鍵は「Android Keystore」を利用して安全に保管されます。)
上記のサンプルコードのように、保存する文字列を個別に暗号化するのも良いですが、SharedPreferencesファイル内に保存するデータを暗号化するのであれば「EncryptedSharedPreferences」を利用するほうが便利かと思います。以下の記事で分かりやすく解説されているので、ご興味のある方はご参照ください。
root化されたデバイスにおいて、暗号鍵をどの程度保護できるのか?
悪意のある第三者にデバイスを窃取され、さらにそのデバイスをroot化されてしまった場合(あるいは、元々root化してあった場合)、「Android Keystore」は暗号鍵をどの程度まで保護することができるのでしょうか。このテーマに関連するドキュメントや記事に目を通し、内容を総合したところ、概ね以下のような状況であると考えられます。
- デバイスがroot化されていたとしても、暗号鍵そのものを抜き出すことはできない。
- しかし、暗号鍵を利用してデータを復号することは可能である。
「Android Keystore」がこれまでOSのバージョンアップに伴って進化してきたことは先に記載した通りですが、その過程で"ソフトウェアで構成されたAndroid Keystore"から"セキュアハードウェアを利用するAndroid Keystore"へと仕組みも大きく変わってきました。最新のデバイスの"Android Keystore"はほぼ後者と考えられ、このタイプでは暗号鍵は Android OS とは分離されたセキュアハードウェアで生成および使用されるため、Android OS ですら直接アクセスすることはできないようになっています。このため、Android OS を自由に操作できるroot化デバイスであっても、暗号鍵自体を抽出することはできません。("ソフトウェアで構成されたAndroid Keystore"では、暗号鍵の抽出が可能だったようです。)
一方で、"暗号鍵を利用することはできる"とはどういうことでしょうか。これは、暗号鍵の内容は分からないけれど、暗号鍵を構成するファイル(/data/misc/keystore/内に保存されている)を複製し、それを利用することでデータの復号処理を行うことは可能である、ということを指しています。例として、デバイス内にアプリAとアプリBが存在し、アプリAは重要データを「Android Keystore」を利用して暗号化しているものとします。このとき、/data/misc/keystore/内にはアプリAの暗号鍵を構成するファイルが置かれているので、そのファイルを複製してアプリB用の暗号鍵ファイルを作成し、アプリBで「Android Keystore」を利用して暗号化/復号処理を行うと、アプリAの暗号鍵と同じものを利用することができる、というわけです。詳細はこちらのドキュメントをご参照ください。
また、そもそもそんなことをしなくても、Frida等のコードインジェクションツールを利用することで、アプリAにデータの復号処理を行わせ、復号されたデータを得る、ということも可能です。
これらのことから、root化されたデバイスにおいても暗号鍵自体を外部に抽出されないようにはできるけれど、その鍵で暗号化されたデータを完全に保護することはできない、と考えられます。(そもそもroot化されたデバイス上でデータを完璧に保護する、ということ自体が無理な話かもしれませんが…)
ただ、いずれの方法にしても、「Android Keystore」を利用して暗号化されたデータを復号するには、攻撃者側にそれなりのスキルと労力が必要となります。攻撃者側の意欲を低下させるには十分な障壁と言えるかもしれません。アプリのデータを平文のまま保存しておくよりも、はるかに安全性は高いでしょう。
まとめ
ここまで、「Android Keystore」の要点を解説してみましたが、いかがでしたでしょうか。モバイルアプリの脆弱性診断をしていると、アプリ内の重要なデータが適切に暗号化されていないケースを頻繁に目にします。もしかしたら、脅威や発生する被害を理解したうえでリスクを受容する判断をしているためにそうなっているのかもしれませんが、ここまで記載した通り「Android Keystore」の利用はそれほど難しい実装を必要としません。もし、データの保存方法に悩まれている場合は、「Android Keystore」による鍵の生成や、暗号化/復号処理を利用することを選択肢の一つとしてご検討いただきたいと思います。
なお本記事は、可能な限り誤解を生まないよう慎重に記載しましたが、もしかしたら誤った理解が含まれているかもしれません。恐れ入りますが、そのような記載がありましたら、コメント等でお知らせ頂けますと幸いです。
最後に、長文となりましたが、本記事をお読みいただきありがとうございました。
Discussion