この記事はAndroidアドベントカレンダー 24日の記事です。一体、何が悲しくて聖夜に技術エントリーを書かないといけないのでしょう。悔しいので、お堅そうな名前しているConcealライブラリの中身を読み込んで、丸裸にしてやることにしました。
- 使い方については、こちらの記事を参照いただくといいと思います。
Concealとは
- Facebookが開発したライブラリです。共通鍵暗号アルゴリズム AES(256bit)と暗号利用モード GCMを用いた暗号化処理を代行します。
- 基本的には一箇所に保存され、転送されない想定のデータを対象にしています。
- https://github.com/facebook/conceal#encryption
Concealライブラリの中身
Concealのgithubページ・上記Qiita記事に載っているので実装は割愛しますが、大きく4ステップがあります。
- KeyChainインスタンスの生成
- Cryptoインスタンスの生成
- ライブラリのロード状況チェック
- 暗号化
それでは各ステップでConcealが何をしているか読み込んでいきましょう。
KeyChainインスタンスの生成
KeyChainは共通鍵や関係するデータのライフサイクルに関わるメソッド群を定義したインターフェースです。これを実装したSharedPrefsBackedKeyChainインスタンスがまず生成されます。ファイル名の通り、SharedPrefをベースにしていますが、暗号化に欠かせない疑似乱数生成器(PRNG)の生成と、暗号化時の鍵長を設定します。
PRNGの生成 と 既知の脆弱性対応
- 該当コード
# SharedPrefesBackedKeyChain.java
SecureRandomFix.createLocalSecureRandom()
PRNGはOpenSSLを利用して生成されますが、Jelly Bean(16~18)には初期化プロセスに脆弱性がありました。 この脆弱性をつくことで暗号文の強度が低下し、BitCoinアプリ内のウォレットの盗難などができてしまう模様です。Googleはこれへの対処方をDeveloper Blogに公開していますが、これをConcealがカバーしてくれています。
private static void tryApplyOpenSSLFix() {
try {
// Mix in the device- and invocation-specific seed.
Class.forName("org.apache.harmony.xnet.provider.jsse.NativeCrypto")
.getMethod("RAND_seed", byte[].class)
.invoke(null, generateSeed());
// Mix output of Linux PRNG into OpenSSL's PRNG
int bytesRead = (Integer) Class.forName(
"org.apache.harmony.xnet.provider.jsse.NativeCrypto")
.getMethod("RAND_load_file", String.class, long.class)
.invoke(null, DEV_URANDOM, 1024);
if (bytesRead != 1024) {
throw new IOException(
"Unexpected number of bytes read from Linux PRNG: "
+ bytesRead);
}
} catch (Exception e) {
throw new SecurityException("Failed to seed OpenSSL PRNG", e);
}
}
暗号化時の鍵長を設定
crypto\CryptoConfig.java
で、鍵長・初期化ベクトル長・タグ長の組み合わせがenumで定義されています。とはいえ、その組み合わせは2通りしかなく、しかも鍵長にしか違いはありません。2016年4月にリリースされたv1.1から、デフォルト推奨の鍵長が256bitになりましたので、基本はCryptoConfig.KEY_256
を使いましょう。
- デフォルトenum:
- cipherID: 2
- 鍵長: 256bits
- 初期化ベクトル長: 12bits
- タグ長: 16bits
Cryptoインスタンスの生成
前述のKeyChainをセットしAndoidConceal().get().createDefaultCrypto(keyChain)
で、生成しています。AndroidConceal().get()内でまた SecureRandomFix.createLocalSecureRandom()
を呼び出しています。おそらくこれは旧バージョンと新バージョンでの使用方法の差異に発する実装かと思われます。また、Cryptoインスタンス生成時に暗号化アルゴリズム(AES-GCM)が決定されます。今後、これが拡張されて他のアルゴリズムや暗号利用モードが利用できるようになる...ことはないでしょう。多分。
ライブラリのロード状況チェック
ConcealのAESアルゴリズム・GCMはCで書かれているため、Conceal内でNativeライブラリ"crypto"がロードされます。このチェックをcrypto.isAvailable()
がしています
暗号化
Concealで暗号化は、平文を書き込む出力ストリームをラップすることで実現されます。デフォルトの実装では、Cryptoインスタンスを通じてBufferedOutputSreamをラップし、そのストリームに平文を書き込む形になります。
出力ストリームのラップ
実際のラップは、Cryptoインスタンス内のmCryptoAlgo変数が担当します。
# Crypto.java
public OutputStream getCipherOutputStream(OutputStream cipherStream, Entity entity, byte[] encryptBuffer)
throws IOException, CryptoInitializationException, KeyChainException {
return mCryptoAlgo.wrap(cipherStream, entity, encryptBuffer);
}
wrap内では、KeyChain内のPRNGから初期化ベクトルが生成され、他のメタデータと一緒に、ラップされる前の出力ストリームに書き込まれます。また、共有鍵もこのタイミングで生成されます。KeyChainのgetCipherKey()は、 maybeGenerateKey()を呼び出し、鍵を生成(もしくは取得)し、SharedPreferenceに保存します。
# CryptoAlgoGcm.java
@Override
public OutputStream wrap(OutputStream cipherStream, Entity entity, byte[] buffer)
throws IOException, CryptoInitializationException, KeyChainException {
cipherStream.write(VersionCodes.CIPHER_SERIALIZATION_VERSION);
cipherStream.write(mConfig.cipherId);
byte[] iv = mKeyChain.getNewIV();
NativeGCMCipher gcmCipher = new NativeGCMCipher(mNativeLibrary);
gcmCipher.encryptInit(mKeyChain.getCipherKey(), iv);
cipherStream.write(iv);
}
# SharedPrefsBackedKeyChain.java
@Override
public synchronized byte[] getCipherKey() throws KeyChainException {
if (!mSetCipherKey) {
mCipherKey = maybeGenerateKey(CIPHER_KEY_PREF, mCryptoConfig.keyLength);
}
mSetCipherKey = true;
return mCipherKey;
}
private byte[] generateAndSaveKey(String pref, int length) throws KeyChainException {
byte[] key = new byte[length];
mSecureRandom.nextBytes(key);
// Store the session key.
SharedPreferences.Editor editor = mSharedPreferences.edit();
editor.putString(
pref,
encodeForPrefs(key));
editor.commit();
return key;
}
その後にGCM特有のデータと一緒に、AES-GCMで入力データを暗号化されるNativeGCMCipherOutputStreamが生成されます。
# CryptoAlgoGcm.java
@Override
public OutputStream wrap(OutputStream cipherStream, Entity entity, byte[] buffer)
throws IOException, CryptoInitializationException, KeyChainException {
byte[] entityBytes = entity.getBytes();
computeCipherAad(gcmCipher, VersionCodes.CIPHER_SERIALIZATION_VERSION, mConfig.cipherId, entityBytes);
return new NativeGCMCipherOutputStream(cipherStream, gcmCipher, buffer, mConfig.tagLength);
}
暗号化
上記で生成したNativeGCMCipherOutputStreamに平文がwriteされると、NativeGCMCipher.updateが呼ばれ、gcm.c内のJava_com_facebook_crypto_cipher_NativeGCMCipher_nativeUpdateAadで暗号化されるのかと思われます。AndroidのNDKの仕組みに詳しくないので、間違っていたら教えて下さい。
# NativeGCMCipherOutputStream.java
@Override
public void write(byte[] buffer, int offset, int count)
throws IOException {
if (buffer.length < offset + count) {
throw new ArrayIndexOutOfBoundsException(offset + count);
}
int times = count / mUpdateBufferChunkSize;
int remainder = count % mUpdateBufferChunkSize;
for (int i = 0; i < times; ++i) {
int written = mCipher.update(buffer, offset, mUpdateBufferChunkSize, mUpdateBuffer, 0);
mCipherDelegate.write(mUpdateBuffer, 0, written);
offset += mUpdateBufferChunkSize;
}
if (remainder > 0) {
int written = mCipher.update(buffer, offset, remainder, mUpdateBuffer, 0);
mCipherDelegate.write(mUpdateBuffer, 0, written);
}
}
以上が、一般的なConcealの暗号化が行われるステップになります。
Conceal - 他の機能
より単純な暗号化の実装
Cryptoインスタンスの生成までは同じですが、そのあとはただ一行「encrypt()」でおわる実装方法です。もう片方との違いは、出力ストリームが受け取れるバイト長が固定であることです。一回生成したら変更がないイミュータブルなデータ、もしくはハッシュ化したデータを暗号化したい時に使えるかもしれません。
KeyChain keyChain = new SharedPrefsBackedKeyChain(context,CryptoConfig.KEY_256);
Crypto crypto = AndroidConceal.get().createDefaultCrypto(keyChain);
crypto.encrypt(plainTextInByte, Entity.create("unique_id"))
パスワードベースの鍵の生成
Concealは基本的に鍵の生成・初期化ベクトルの作成など、暗号化で必要な処理は全てやってくれます。もし、そういった利便性を投げ捨ててオリジナルな鍵を作りたいのであれば、パスワードから生成することができます。しかし、あくまでも鍵を生成するところまでで、そのあとの暗号化処理、初期化ベクトルの作成、鍵の保存を全て自前で実装する必要があります。
AndroidConceal.get().createPasswordBasedKeyDerivation()
.setIterations(10000)
.setPassword("P4$$word")
.setSalt(buffer)
.setKeyLengthInBytes(16) // in bytes
.generate();
MAC値の取得
Concealを利用すれば、デフォルトの暗号利用モードがGCMになってます。GCMは認証付きアルゴリズムなので、MAC値の計算は必要ありません。しかし、パスワードベースの鍵の生成をした場合、複合時に完全性・認証をしたほうがいいので、その場合はこれをつかうといいと思います。なお、複合に完全性・認証チェックがなぜ必要なのかを知りたい場合は、オラクルパディング攻撃でggrと良いでしょう。
OutputStream fileStream = new BufferedOutputStream(new FileOutputStream(file));
OutputStream outputStream = crypto.getCipherOutputStream(fileStream,Entity.create("entity_id"));
crypto.getMacOutputStream(outputStream,Entity.create("unique_mac_id")); // probably
outputStream.write(plainTextBytes);
outputStream.close();
Concealを使うにあたって気をつけたいこと
最後にConcealを使うにあたって気をつけたいことを書きましょう。
- 上述しましたが、ver1.1から256bit鍵長が推奨されています。記事の中にはver1.1以前のもあるので気をつけましょう。
- 鍵をSharedPreferenceに保存しているので、Root化された端末では抜かれてしまいます。
- API18以降を対象とするならおとなしくKeyStoreに保存しましょう。その場合ユーザーが入力するパスワードがなければ開けない
- というより、大事な情報は基本サーバに起き、Android端末には保存しないようにしましょう。必要な時にだけfetchしに行きましょう。
- もし、サーバにおけない場合は...いい案があったら教えてください。Root化された端末では利用できないようにする...とか?