5. セキュリティ機能の使い方

暗号や電子署名、Permissionなど、Androidにはさまざまなセキュリティ機能が用意されている。これらのセキュリティ機能は取り扱いを間違えるとセキュリティ機能が十分に発揮されず抜け道ができてしまう。この章では開発者がセキュリティ機能を活用するシーンを想定した記事を扱う。

5.1. パスワード入力画面を作る

5.1.1. サンプルコード

パスワード入力画面を作る際、セキュリティ上考慮すべきポイントについて述べる。ここではパスワードの入力に関する内容のみとする。パスワードの保存方法については今後の版にて別途記事を設ける予定である。

_images/image56.png

図 5.1.1 パスワード入力画面例

ポイント:

  1. 入力したパスワードはマスク表示(●で表示)する
  2. パスワードを平文表示するオプションを用意する
  3. パスワード平文表示時の危険性を注意喚起する

ポイント: 前回入力したパスワードを扱う場合には上記ポイントに加え、下記ポイントにも気を付けること

  1. Activity初期表示時に前回入力したパスワードがある場合、前回入力パスワードの桁数を推測されないよう固定桁数の●文字でダミー表示する
  2. 前回入力パスワードをダミー表示しているとき、「パスワードを表示」した場合、前回入力パスワードをクリアして、新規にパスワードを入力できる状態とする
  3. 前回入力パスワードをダミー表示しているとき、ユーザーがパスワードを入力しようとした場合、前回入力パスワードをクリアし、ユーザーの入力を新たなパスワードとして扱う
password_activity.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:orientation="vertical"
    android:padding="10dp" >

    <!-- パスワード項目のラベル -->
    <TextView
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="@string/password" />

    <!-- パスワード入力項目 -->
    <!-- ★ポイント1★ 入力したパスワードはマスク表示(●で表示)する -->
    <EditText
        android:id="@+id/password_edit"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:hint="@string/hint_password"
        android:inputType="textPassword" />

    <!-- ★ポイント2★ パスワードを平文表示するオプションを用意する -->
    <CheckBox
        android:id="@+id/password_display_check"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="@string/display_password" />

    <!-- ★ポイント3★ パスワード平文表示時の危険性を注意喚起する -->
    <TextView
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="@string/alert_password" />

    <!-- キャンセル、OKボタン -->
    <LinearLayout
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="50dp"
        android:gravity="center"
        android:orientation="horizontal" >

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="onClickCancelButton"
            android:text="@android:string/cancel" />

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="onClickOkButton"
            android:text="@android:string/ok" />
    </LinearLayout>
</LinearLayout>

次のPasswordActivity.javaの最後に配置した3つのメソッドは用途に合わせて実装内容を調整すること。

  • private String getPreviousPassword()
  • private void onClickCancelButton(View view)
  • private void onClickOkButton(View view)
PasswordActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.password.passwordinputui;

import android.app.Activity;
import android.os.Bundle;
import android.text.Editable;
import android.text.InputType;
import android.text.TextWatcher;
import android.view.View;
import android.view.WindowManager;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.CompoundButton.OnCheckedChangeListener;
import android.widget.EditText;
import android.widget.Toast;

public class PasswordActivity extends Activity {

    // 状態保存用のキー
    private static final String KEY_DUMMY_PASSWORD = "KEY_DUMMY_PASSWORD";

    // Activity内のView
    private EditText mPasswordEdit;
    private CheckBox mPasswordDisplayCheck;

    // パスワードがダミー表示かを表すフラグ
    private boolean mIsDummyPassword;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.password_activity);

        // スクリーンキャプチャを無効化する
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);

        // Viewの取得
        mPasswordEdit = (EditText) findViewById(R.id.password_edit);
        mPasswordDisplayCheck = (CheckBox) findViewById(R.id.password_display_check);

        // 前回入力パスワードがあるか
        if (getPreviousPassword() != null) {
            // ★ポイント4★ Activity初期表示時に前回入力したパスワードがある場合、
            // 前回入力パスワードの桁数を推測されないよう固定桁数の●文字でダミー表示する

            // 表示はダミーパスワードにする
            mPasswordEdit.setText("**********");
            // パスワード入力時にダミーパスワードをクリアするため、テキスト変更リスナーを設定
            mPasswordEdit.addTextChangedListener(new PasswordEditTextWatcher());
            // ダミーパスワードフラグを設定する
            mIsDummyPassword = true;
        }

        // パスワードを表示するオプションのチェック変更リスナーを設定
        mPasswordDisplayCheck
            .setOnCheckedChangeListener(new OnPasswordDisplayCheckedChangeListener());
    }

    @Override
    public void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);

        // 画面の縦横変更でActivityが再生成されないよう指定した場合には不要
        // Activityの状態保存
        outState.putBoolean(KEY_DUMMY_PASSWORD, mIsDummyPassword);
    }

    @Override
    public void onRestoreInstanceState(Bundle savedInstanceState) {
        super.onRestoreInstanceState(savedInstanceState);

        // 画面の縦横変更でActivityが再生成されないよう指定した場合には不要
        // Activityの状態の復元
        mIsDummyPassword = savedInstanceState.getBoolean(KEY_DUMMY_PASSWORD);
    }

    /**
     * パスワードを入力した場合の処理
     */
    private class PasswordEditTextWatcher implements TextWatcher {

        public void beforeTextChanged(CharSequence s,
                                      int start, int count, int after) {
            // 未使用
        }

        public void onTextChanged(CharSequence s,
                                  int start, int before, int count) {
            // ★ポイント6★ 前回入力パスワードをダミー表示しているとき、ユーザーがパスワードを入力しようと
            // した場合、前回入力パスワードをクリアし、ユーザーの入力を新たなパスワードとして扱う
            if (mIsDummyPassword) {
                // ダミーパスワードフラグを設定する
                mIsDummyPassword = false;
                // パスワードを入力した文字だけにする
                CharSequence work = s.subSequence(start, start + count);
                mPasswordEdit.setText(work);
                // カーソル位置が最初に戻るので最後にする
                mPasswordEdit.setSelection(work.length());
            }
        }

        public void afterTextChanged(Editable s) {
            // 未使用
        }

    }

    /**
     * パスワードの表示オプションチェックを変更した場合の処理
     */
    private class OnPasswordDisplayCheckedChangeListener implements
                                                             OnCheckedChangeListener {

        public void onCheckedChanged(CompoundButton buttonView,
                                     boolean isChecked) {
            // ★ポイント5★ 前回入力パスワードをダミー表示しているとき、「パスワードを表示」した場合、
            // 前回入力パスワードをクリアして、新規にパスワードを入力できる状態とする
            if (mIsDummyPassword && isChecked) {
                // ダミーパスワードフラグを設定する
                mIsDummyPassword = false;
                // パスワードを空表示にする
                mPasswordEdit.setText(null);
            }

            // カーソル位置が最初に戻るので今のカーソル位置を記憶する
            int pos = mPasswordEdit.getSelectionStart();

            // ★ポイント2★ パスワードを平文表示するオプションを用意する
            // InputTypeの作成
            int type = InputType.TYPE_CLASS_TEXT;
            if (isChecked) {
                // チェックON時は平文表示
                type |= InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD;
            } else {
                // チェックOFF時はマスク表示
                type |= InputType.TYPE_TEXT_VARIATION_PASSWORD;
            }

            // パスワードEditTextにInputTypeを設定
            mPasswordEdit.setInputType(type);

            // カーソル位置を設定する
            mPasswordEdit.setSelection(pos);
        }

    }

    // 以下のメソッドはアプリに合わせて実装すること

    /**
     * 前回入力パスワードを取得する
     *
     * @return 前回入力パスワード
     */
    private String getPreviousPassword() {
        // 保存パスワードを復帰させたい場合にパスワード文字列を返す
        // パスワードを保存しない用途ではnullを返す
        return "hirake5ma";
    }

    /**
     * キャンセルボタンの押下処理
     *
     * @param view
     */
    public void onClickCancelButton(View view) {
        // Activityを閉じる
        finish();
    }

    /**
     * OKボタンの押下処理
     *
     * @param view
     */
    public void onClickOkButton(View view) {
        // passwordを保存するとか認証に使うとか必要な処理を行う

        String password = null;

        if (mIsDummyPassword) {
            // 最後までダミーパスワード表示だった場合は前回入力パスワードを確定パスワードとする
            password = getPreviousPassword();
        } else {
            // ダミーパスワード表示じゃない場合はユーザー入力パスワードを確定パスワードとする
            password = mPasswordEdit.getText().toString();
        }

        // パスワードをToast表示する
        Toast.makeText(this, "password is \"" + password + "\"",
                       Toast.LENGTH_SHORT).show();

        // Activityを閉じる
        finish();
    }
}

5.1.2. ルールブック

パスワード入力画面を作る際には以下のルールを守ること。

  1. パスワードを入力するときにはマスク表示(●で表示する)機能を用意する (必須)
  2. パスワードを平文表示するオプションを用意する (必須)
  3. Activity起動時はパスワードをマスク表示にする (必須)
  4. 前回入力したパスワードを表示する場合、ダミーパスワードを表示する (必須)

5.1.2.1. パスワードを入力するときにはマスク表示(●で表示する)機能を用意する (必須)

スマートフォンは電車やバス等の人混みで利用されることが多く、第三者にパスワードを盗み見られるリスクが大きい。アプリの仕様として、パスワードをマスク表示する機能が必要である。

パスワードを入力するEditTextをマスク表示する方法には、静的にレイアウトXMLで指定する方法と、動的にプログラム上で切り替える方法の2種類がある。前者は、android:inputType属性に"textPassword"を指定することで実現でき、またandroid:password属性でも実現できる。後者は、EditTextクラスのsetInputType()メソッドでEditTextの入力タイプにInputType.TYPE_TEXT_VARIATION_PASSWORDを追加することで実装できる。

以下、それぞれのサンプルコードを示す。

レイアウトXMLで指定する方法

password_activity.xml
    <!-- パスワード入力項目 -->
    <!-- android:passwordをtrueに設定する -->
    <EditText
        android:id="@+id/password_edit"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:hint="@string/hint_password"
        android:inputType="textPassword" />

Activity内で指定する方法

PasswordActivity.java
    // パスワード表示タイプを設定
    // InputTypeにTYPE_TEXT_VARIATION_PASSWORDを設定する
    EditText passwordEdit = (EditText) findViewById(R.id.password_edit);
    int type = InputType.TYPE_CLASS_TEXT
            | InputType.TYPE_TEXT_VARIATION_PASSWORD;
    passwordEdit.setInputType(type);

5.1.2.2. パスワードを平文表示するオプションを用意する (必須)

スマートフォンにおけるパスワード入力はタッチパネルでの入力となるため、PC でキーボード入力する場合と比較すると誤入力が生じやすい。その入力の煩わしさからユーザーは単純なパスワードを利用してしまう可能性があり、かえって危険である。また複数回のパスワード入力失敗によりアカウントをロックするなどのポリシーがある場合、誤入力はできるだけ避けるようにする必要もある。それらの解決策として、パスワードを平文表示できるオプションを用意することで、安全なパスワードを利用してもらえるようになる。

ただし、パスワードを平文表示した際に覗き見される可能性もあるため、そのオプションを使う際に、ユーザーに背後からの覗き見への注意を促す必要がある。また、平文表示するオプションをつけた場合、平文表示の時間を設定するなど平文表示の自動解除を行う仕組みも用意する必要がある。パスワードの平文表示の制限については今後の版にて別途記事を設ける予定である。そのため、この版のサンプルコードにはパスワードの平文表示の制限は含めていない。

_images/image56-57.png

図 5.1.2 パスワードの平文表示

EditTextのInputType指定で、マスク表示と平文表示を切り替えることができる

PasswordActivity.java
    /**
     * パスワードの表示オプションチェックを変更した場合の処理
     */
    private class OnPasswordDisplayCheckedChangeListener implements
            OnCheckedChangeListener {

        public void onCheckedChanged(CompoundButton buttonView,
                boolean isChecked) {
            // ★ポイント5★ 前回入力パスワードをダミー表示しているとき、「パスワードを表示」した場合、
            // 前回入力パスワードをクリアして、新規にパスワードを入力できる状態とする
            if (mIsDummyPassword && isChecked) {
                // ダミーパスワードフラグを設定する
                mIsDummyPassword = false;
                // パスワードを空表示にする
                mPasswordEdit.setText(null);
            }

            // カーソル位置が最初に戻るので今のカーソル位置を記憶する
            int pos = mPasswordEdit.getSelectionStart();

            // ★ポイント2★ パスワードを平文表示するオプションを用意する
            // InputTypeの作成
            int type = InputType.TYPE_CLASS_TEXT;
            if (isChecked) {
                // チェックON時は平文表示
                type |= InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD;
            } else {
                // チェックOFF時はマスク表示
                type |= InputType.TYPE_TEXT_VARIATION_PASSWORD;
            }

            // パスワードEditTextにInputTypeを設定
            mPasswordEdit.setInputType(type);

            // カーソル位置を設定する
            mPasswordEdit.setSelection(pos);
        }

    }

5.1.2.3. Activity起動時はパスワードをマスク表示にする (必須)

意図せずパスワード表示してしまい、第三者に見られることを防ぐため、Activity起動時にパスワードを表示するオプションのデフォルト値はオフにするべきである。デフォルト値は安全側に定めるのが基本である。

5.1.2.4. 前回入力したパスワードを表示する場合、ダミーパスワードを表示する (必須)

前回入力したパスワードを指定する場合、第三者にパスワードのヒントを与えないように、固定文字数のマスク文字(●など)でダミー表示するべきである。また、ダミー表示時に「パスワードを表示する」とした場合は、パスワードをクリアしてから平文表示モードにする。これにより、スマートフォンが盗難される等によって第三者の手に渡ったとしても前回入力したパスワードが盗み見られる危険性を低く抑えることができる。なお、ダミー表示時にユーザーがパスワードを入力しようとした場合には、ダミー表示を解除して通常の入力状態に戻す必要がある。

前回入力したパスワードを表示する場合、ダミーパスワードを表示する

PasswordActivity.java
    @Override
    public void onCreate(Bundle savedInstanceState) {

        // 〜省略〜

        // 前回入力パスワードがあるか
        if (getPreviousPassword() != null) {
            // ★ポイント4★ Activity初期表示時に前回入力したパスワードがある場合、
            // 前回入力パスワードの桁数を推測されないよう固定桁数の●文字でダミー表示する

            // 表示はダミーパスワードにする
            mPasswordEdit.setText("**********");
            // パスワード入力時にダミーパスワードをクリアするため、テキスト変更リスナーを設定
            mPasswordEdit.addTextChangedListener(new PasswordEditTextWatcher());
            // ダミーパスワードフラグを設定する
            mIsDummyPassword = true;
        }
        // 〜省略〜
    }

    /**
     * 前回入力パスワードを取得する
     *
     * @return 前回入力パスワード
     */
    private String getPreviousPassword() {
        // 保存パスワードを復帰させたい場合にパスワード文字列を返す
        // パスワードを保存しない用途ではnullを返す
        return "hirake5ma";
    }

ダミー表示時は、パスワードを表示するオプションをオンにすると表示内容をクリアする

PasswordActivity.java
    /**
     * パスワードの表示オプションチェックを変更した場合の処理
     */
    private class OnPasswordDisplayCheckedChangeListener implements
            OnCheckedChangeListener {

        public void onCheckedChanged(CompoundButton buttonView,
                boolean isChecked) {
            // ★ポイント5★ 前回入力パスワードをダミー表示しているとき、「パスワードを表示」した場合、
            // 前回入力パスワードをクリアして、新規にパスワードを入力できる状態とする
            if (mIsDummyPassword && isChecked) {
                // ダミーパスワードフラグを設定する
                mIsDummyPassword = false;
                // パスワードを空表示にする
                mPasswordEdit.setText(null);
            }
            // 〜省略〜
        }
    }

ダミー表示時にユーザーがパスワードを入力した場合には、ダミー表示を解除する

PasswordActivity.java
    // 状態保存用のキー
    private static final String KEY_DUMMY_PASSWORD = "KEY_DUMMY_PASSWORD";

    // 〜省略〜

    // パスワードがダミー表示かを表すフラグ
    private boolean mIsDummyPassword;

    @Override
    public void onCreate(Bundle savedInstanceState) {

        // 〜省略〜

        // 前回入力パスワードがあるか
        if (getPreviousPassword() != null) {
            // ★ポイント4★ Activity初期表示時に前回入力したパスワードがある場合、
            // 前回入力パスワードの桁数を推測されないよう固定桁数の●文字でダミー表示する

            // 表示はダミーパスワードにする
            mPasswordEdit.setText("**********");
            // パスワード入力時にダミーパスワードをクリアするため、テキスト変更リスナーを設定
            mPasswordEdit.addTextChangedListener(new PasswordEditTextWatcher());
            // ダミーパスワードフラグを設定する
            mIsDummyPassword = true;
        }
        // 〜省略〜
    }

    @Override
    public void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);

        // 画面の縦横変更でActivityが再生成されないよう指定した場合には不要
        // Activityの状態保存
        outState.putBoolean(KEY_DUMMY_PASSWORD, mIsDummyPassword);
    }

    @Override
    public void onRestoreInstanceState(Bundle savedInstanceState) {
        super.onRestoreInstanceState(savedInstanceState);

        // 画面の縦横変更でActivityが再生成されないよう指定した場合には不要
        // Activityの状態の復元
        mIsDummyPassword = savedInstanceState.getBoolean(KEY_DUMMY_PASSWORD);
    }

    /**
     * パスワードを入力した場合の処理
     */
    private class PasswordEditTextWatcher implements TextWatcher {

        public void beforeTextChanged(CharSequence s, int start, int count,
                int after) {
            // 未使用
        }

        public void onTextChanged(CharSequence s, int start, int before,
                int count) {
            // ★ポイント6★ 前回入力パスワードをダミー表示しているとき、ユーザーがパスワードを入力しようと
            // した場合、前回入力パスワードをクリアし、ユーザーの入力を新たなパスワードとして扱う
            if (mIsDummyPassword) {
                // ダミーパスワードフラグを設定する
                mIsDummyPassword = false;
                // パスワードを入力した文字だけにする
                CharSequence work = s.subSequence(start, start + count);
                mPasswordEdit.setText(work);
                // カーソル位置が最初に戻るので最後にする
                mPasswordEdit.setSelection(work.length());
            }
        }

        public void afterTextChanged(Editable s) {
            // 未使用
        }
    }

5.1.3. アドバンスト

5.1.3.1. ログイン処理について

パスワード入力が求められる場面の代表例はログイン処理である。ログイン処理で気を付けるポイントをいくつか紹介する。

ログイン失敗時のエラーメッセージ

ログイン処理ではID(アカウント)とパスワードの2つの情報を入力する。ログイン失敗時にはIDが存在しない場合と、IDは存在するがパスワードが間違っている場合の2つがある。ログイン失敗のメッセージでこの2つの場合を区別して表示すると、攻撃者は「指定したIDが存在するか否か」を推測できてしまう。このような推測を許さないためにも、ログイン失敗時のメッセージでは、上記2つの場合を区別せずに下記のように表示すべきである。

メッセージ例: ログインID または パスワード が間違っています。

自動ログイン機能

一度、ログイン処理が成功すると次回以降はログインIDとパスワードの入力を省略して、自動的にログインを行う機能がある。自動ログイン機能は煩わしい入力が省略できるので利便性が高まるが、その反面スマートフォンが盗難された場合に第三者に悪用されるリスクが伴う。

第三者に悪用された場合の被害が受け入れられる用途か、十分なセキュリティ対策が可能な場合にのみ、自動ログイン機能は利用することができる。例えば、オンラインバンキングアプリの場合、第三者に端末を操作されると金銭的な被害が出るので自動ログイン機能に合わせてセキュリティ対策が必須となる。対策としては、「決済処理などの金銭的な処理が発生する直前にはパスワードの再入力を求める」、「自動ログイン設定時にユーザーに対して十分に注意を喚起し、確実な端末のロックを促す」などいくつか考えられる。自動ログインの利用にあたっては、これらの対策を前提に利便性とリスクを勘案して、慎重な検討を行うべきである。

5.1.3.2. パスワード変更について

一度設定したパスワードを別のパスワードに変更する場合、以下の入力項目を画面上に用意すべきである。

  • 現在のパスワード
  • 新しいパスワード
  • 新しいパスワード(入力確認用)

自動ログイン機能がついている場合、第三者がアプリを利用できる可能性がある。その場合、勝手にパスワードを変更されないよう、現在のパスワードの入力を求める必要がある。また、新しいパスワードが入力ミスで使用不能に陥る危険を減らすため、新しいパスワードは2回、入力を求める必要がある。

5.1.3.3. システムの「パスワードを表示」設定メニューについて

Androidの設定メニューの中に「パスワードを表示」という設定がある。

設定 > セキュリティ > パスワードを表示

Android 5.0より前のバージョンまで、この手順で設定できる。ただしAndroid 5.0以降では、「パスワードを表示」はチェックボックスからトグルボタンに変更されている。

_images/image61.png

図 5.1.3 設定 ― パスワードを表示

「パスワードを表示」設定をオンにすると最後に入力した1文字が平文表示となる。一定時間(2秒程度)経過後、または次の文字が入力されると平文表示されていた文字はマスク表示される。オフにすると、入力直後からマスク表示となる。これはシステム全体に影響する設定であり、EditTextのパスワード表示機能を使用しているすべてのアプリに適用される。

_images/image62-63.png

図 5.1.4 パスワード入力時の文字の表示

5.1.3.4. スクリーンキャプチャの無効化

パスワード入力画面ではパスワードなどの個人情報が画面上に表示される可能性がある。そのような画面では第三者によってスクリーンキャプチャから個人情報が流出してしまう恐れがある。よってパスワード入力画面などの個人情報が表示されてしまう恐れのある画面ではスクリーンキャプチャを無効にしておく必要がある。スクリーンキャプチャの無効化はWindowManagerにaddFlagでFLAG_SECUREを設定することで実現できる。

5.2. PermissionとProtection Level

PermissionのProtection Levelにはnormal, dangerous, signature, signatureOrSystemの4種類がある。その他に「development」「system」「appop」も存在するが、一般的なアプリでは使用しないので本章での説明は省略する。PermissionはどのProtection Levelであるかによってそれぞれ、Normal Permission, Dangerous Permission, Signature Permission, SignatureOrSystem Permissionと呼ばれる。以下、このような名称を使う。

5.2.1. サンプルコード

5.2.1.1. Android OS既定のPermissionを利用する方法

Android OSは電話帳やGPSなどのユーザー資産をマルウェアから保護するためのPermissionというセキュリティの仕組みがある。Android OSが保護対象としている、こうした情報や機能にアクセスするアプリは、明示的にそれらにアクセスするための権限(Permission)を利用宣言しなければならない。ユーザー確認が必要なPermissionでは、そのPermissionを利用宣言したアプリがインストールされるときに次のようなユーザー確認画面が表示される [1]

[1]Android 6.0(API Level 23)以降では、ユーザー確認と権限の付与はインストール時に行われず、アプリの実行中に権限の利用を要求する仕様に変更された。詳細は「5.2.1.4. Android 6.0以降でDangerous Permissionを利用する方法」および「5.2.3.6. Android 6.0 以降の Permission モデルの仕様変更について」を参照すること。
_images/image66.png

図 5.2.1 Permission 利用宣言確認画面

この確認画面により、ユーザーはそのアプリがどのような機能や情報にアクセスしようとしているのかを知ることができる。もし、アプリの動作に明らかに不必要な機能や情報にアクセスしようとしている場合は、そのアプリは悪意があるアプリの可能性が高い。ゆえに自分のアプリがマルウェアであると疑われないためにも、利用宣言するPermissionは最小限にしなければならない。

ポイント:

  1. 利用するPermissionをAndroidManifest.xmlにuses-permissionで利用宣言する
  2. 不必要なPermissionは利用宣言しない
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.permission.usespermission">

  <!-- ★ポイント1★  利用するPermissionをAndroidManifest.xmlにuses-permissionで利用宣言する -->
  <!-- インターネットにアクセスするPermission -->
  <uses-permission android:name="android.permission.INTERNET"/>

  <!-- ★ポイント2★  不必要なPermissionは利用宣言しない -->
  <!-- アプリの動作に不必要なPermissionを利用宣言していると、ユーザーに不信感を与えてしまう -->
  
  <application
      android:allowBackup="false"
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name" >
    <activity
        android:name=".MainActivity"
        android:label="@string/app_name"
        android:exported="true" >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
  </application>

</manifest>

5.2.1.2. 独自定義のSignature Permissionで自社アプリ連携する方法

Android OSが定義する既定のPermissionの他に、アプリが独自にPermissionを定義することができる。独自定義のPermission(正確には独自定義のSignature Permission)を使えば、自社アプリだけが連携できる仕組みを作ることができる。複数の自社製アプリをインストールした場合に、それぞれのアプリの単機能に加え、アプリ間連携による複合機能を提供することで、複数の自社製アプリをシリーズ販売して収益を上げる、といった用途がある。

サンプルプログラム「独自定義Signature Permission(UserApp)」はサンプルプログラム「独自定義Signature Permission(ProtectedApp)」にstartActivity()する。両アプリは同じ開発者鍵で署名されている必要がある。もし署名した開発者鍵が異なる場合は、UserAppはIntentを送信せず、ProtectedAppは受信したIntentを処理しない。またアドバンストセクションで説明しているインストール順序によるSignature Permission回避の問題にも対処している。

_images/image67.png

図 5.2.2 独自定義の Signature Permission で自社アプリを連携する

ポイント:Componentを提供するアプリ

  1. 独自PermissionをprotectionLevel="signature"で定義する
  2. Componentにはpermission属性で独自Permission名を指定する
  3. ComponentがActivityの場合にはintent-filterを定義しない
  4. ソースコード上で、独自定義Signature Permissionが自社アプリにより定義されていることを確認する
  5. Android Studioからメニュー:Build -> Generated Signed APKと選択し、Componentを利用するアプリと同じ開発者鍵でAPKを署名する
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.permission.protectedapp">

  <!-- ★ポイント1★ 独自PermissionをprotectionLevel="signature"で定義する -->
  <permission
      android:name="org.jssec.android.permission.protectedapp.MY_PERMISSION"
      android:protectionLevel="signature" />

  <application
      android:allowBackup="false"
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name" >

    <!-- ★ポイント2★ Componentにはpermission属性で独自Permission名を指定する -->
    <activity
        android:name=".ProtectedActivity"
        android:exported="true"
        android:label="@string/app_name"
        android:permission="org.jssec.android.permission.protectedapp.MY_PERMISSION" >

      <!-- ★ポイント3★ ComponentがActivityの場合にはintent-filterを定義しない -->
    </activity>
  </application>
</manifest>
ProtectedActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.permission.protectedapp;

import org.jssec.android.shared.SigPerm;
import org.jssec.android.shared.Utils;

import android.app.Activity;
import android.content.Context;
import android.os.Bundle;
import android.widget.TextView;

public class ProtectedActivity extends Activity {

    // 自社のSignature Permission
    private static final String MY_PERMISSION =
        "org.jssec.android.permission.protectedapp.MY_PERMISSION";

    // 自社の証明書のハッシュ値
    private static String sMyCertHash = null;
    private static String myCertHash(Context context) {
        if (sMyCertHash == null) {
            if (Utils.isDebuggable(context)) {
                // debug.keystoreの"androiddebugkey"の証明書ハッシュ値
                sMyCertHash = "0EFB7236 328348A9 89718BAD DF57F544 D5CCB4AE B9DB34BC 1E29DD26 F77C8255";
            } else {
                // keystoreの"my company key"の証明書ハッシュ値
                sMyCertHash = "D397D343 A5CBC10F 4EDDEB7C A10062DE 5690984F 1FB9E88B D7B3A7C2 42E142CA";
            }
        }
        return sMyCertHash;
    }

    private TextView mMessageView;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        mMessageView = (TextView) findViewById(R.id.messageView);

        // ★ポイント4★ ソースコード上で、独自定義Signature Permissionが自社アプリにより定義されていることを確認する
        if (!SigPerm.test(this, MY_PERMISSION, myCertHash(this))) {
            mMessageView.setText("独自定義Signature Permissionが自社アプリにより定義されていない。");
            return;
        }

        // 証明書が一致する場合にのみ、処理を続行する
        mMessageView.setText("独自定義Signature Permissionが自社アプリにより定義されていることを確認できた。");
    }
}
SigPerm.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.shared;

import android.content.Context;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.PermissionInfo;
import android.os.Build;

import static android.content.pm.PackageManager.CERT_INPUT_SHA256;

public class SigPerm {

    public static boolean test(Context ctx, String sigPermName, String correctHash) {
        if (correctHash == null) return false;
        correctHash = correctHash.replaceAll(" ", "");
        try{
            // sigPermNameを定義したアプリのパッケージ名を取得する
            PackageManager pm = ctx.getPackageManager();
            PermissionInfo pi =
                pm.getPermissionInfo(sigPermName, PackageManager.GET_META_DATA);
            String pkgname = pi.packageName;
            // 非Signature Permissionの場合は失敗扱い
            if (pi.protectionLevel != PermissionInfo.PROTECTION_SIGNATURE) return false;
            // pkgnameの実際のハッシュ値と正解のハッシュ値を比較する
            if (Build.VERSION.SDK_INT >= 28) {
                // ★ API Level >= 28 ではPackage ManagerのAPIで直接検証が可能
                return pm.hasSigningCertificate(pkgname,
                                                Utils.hex2Bytes(correctHash),
                                                CERT_INPUT_SHA256);
            } else {
                // API Level < 28 の場合はPkgCertを利用し、ハッシュ値を取得して比較する
                return correctHash.equals(PkgCert.hash(ctx, pkgname));
            }
        } catch (NameNotFoundException e){
            return false;
        }
    }
}
PkgCertWhitelists.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.shared;

import android.content.pm.PackageManager;
import java.util.HashMap;
import java.util.Map;
import android.content.Context;
import android.os.Build;

import static android.content.pm.PackageManager.CERT_INPUT_SHA256;

public class PkgCertWhitelists {
    private Map<String, String> mWhitelists = new HashMap<String, String>();

    public boolean add(String pkgname, String sha256) {
        if (pkgname == null) return false;
        if (sha256 == null) return false;
        
        sha256 = sha256.replaceAll(" ", "");
        if (sha256.length() != 64)
            return false;   // SHA-256は32バイト
        sha256 = sha256.toUpperCase();
        if (sha256.replaceAll("[0-9A-F]+", "").length() != 0)
            return false;   // 0-9A-F 以外の文字がある
        
        mWhitelists.put(pkgname, sha256);
        return true;
    }
    
    public boolean test(Context ctx, String pkgname) {
        // pkgnameに対応する正解のハッシュ値を取得する
        String correctHash = mWhitelists.get(pkgname);

        // pkgnameの実際のハッシュ値と正解のハッシュ値を比較する
        if (Build.VERSION.SDK_INT >= 28) {
            // ★ API Level >= 28 ではPackage ManagerのAPIで直接検証が可能
            PackageManager pm = ctx.getPackageManager();
            return pm.hasSigningCertificate(pkgname,
                                            Utils.hex2Bytes(correctHash),
                                            CERT_INPUT_SHA256);
        } else {
            // API Level < 28 の場合はPkgCertの機能を利用する
            return PkgCert.test(ctx, pkgname, correctHash);
        }
    }
}

★ポイント5★ Android Studioからメニュー:Build -> Generated Signed APKと選択し、Componentを提供するアプリと同じ開発者鍵で署名する

_images/image68.png

図 5.2.3 Component を提供するアプリと同じ開発者鍵で APK を署名する

ポイント:Componentを利用するアプリ

  1. 独自定義Signature Permissionは定義しない
  2. uses-permissionにより独自Permissionを利用宣言する
  3. ソースコード上で、独自定義Signature Permissionが自社アプリにより定義されていることを確認する
  4. 利用先アプリが自社アプリであることを確認する
  5. 利用先ComponentがActivityの場合、明示的Intentを使う
  6. Android Studioからメニュー:Build -> Generated Signed APKと選択し、Componentを提供するアプリと同じ開発者鍵でAPKを署名する
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.permission.userapp">

  <!-- ★ポイント6★ 独自定義Signature Permissionは定義しない -->

  <!-- ★ポイント7★ uses-permissionにより独自Permissionを利用宣言する -->
  <uses-permission
      android:name="org.jssec.android.permission.protectedapp.MY_PERMISSION" />

  <application
      android:allowBackup="false"
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name" >
    <activity
        android:name=".UserActivity"
        android:label="@string/app_name"
        android:exported="true" >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
  </application>
</manifest>
UserActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.permission.userapp;

import org.jssec.android.shared.PkgCert;
import org.jssec.android.shared.SigPerm;
import org.jssec.android.shared.Utils;

import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Toast;

public class UserActivity extends Activity {

    // 利用先のActivity情報
    private static final String TARGET_PACKAGE  =
        "org.jssec.android.permission.protectedapp";
    private static final String TARGET_ACTIVITY =
        "org.jssec.android.permission.protectedapp.ProtectedActivity";

    // 自社のSignature Permission
    private static final String MY_PERMISSION =
        "org.jssec.android.permission.protectedapp.MY_PERMISSION";

    // 自社の証明書のハッシュ値
    private static String sMyCertHash = null;
    private static String myCertHash(Context context) {
        if (sMyCertHash == null) {
            if (Utils.isDebuggable(context)) {
                // debug.keystoreの"androiddebugkey"の証明書ハッシュ値
                sMyCertHash = "0EFB7236 328348A9 89718BAD DF57F544 D5CCB4AE B9DB34BC 1E29DD26 F77C8255";
            } else {
                // keystoreの"my company key"の証明書ハッシュ値
                sMyCertHash = "D397D343 A5CBC10F 4EDDEB7C A10062DE 5690984F 1FB9E88B D7B3A7C2 42E142CA";
            }
        }
        return sMyCertHash;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }

    public void onSendButtonClicked(View view) {

        // ★ポイント8★ ソースコード上で、独自定義Signature Permissionが自社アプリにより定義されていることを確認する
        if (!SigPerm.test(this, MY_PERMISSION, myCertHash(this))) {
            Toast.makeText(this,
                           "独自定義Signature Permissionが自社アプリにより定義されていない。",
                           Toast.LENGTH_LONG).show();
            return;
        }

        // ★ポイント9★ 利用先アプリが自社アプリであることを確認する
        if (!PkgCert.test(this, TARGET_PACKAGE, myCertHash(this))) {
            Toast.makeText(this,
                           "利用先アプリは自社アプリではない。",
                           Toast.LENGTH_LONG).show();
            return;
        }

        // ★ポイント10★ 利用先ComponentがActivityの場合、明示的Intentを使う
        try {
            Intent intent = new Intent();
            intent.setClassName(TARGET_PACKAGE, TARGET_ACTIVITY);
            startActivity(intent);
        } catch(Exception e) {
            Toast.makeText(this,
                           String.format("例外発生:%s", e.getMessage()),
                           Toast.LENGTH_LONG).show();
        }
    }
}
PkgCertWhitelists.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.shared;

import android.content.pm.PackageManager;
import java.util.HashMap;
import java.util.Map;
import android.content.Context;
import android.os.Build;

import static android.content.pm.PackageManager.CERT_INPUT_SHA256;

public class PkgCertWhitelists {
    private Map<String, String> mWhitelists = new HashMap<String, String>();

    public boolean add(String pkgname, String sha256) {
        if (pkgname == null) return false;
        if (sha256 == null) return false;
        
        sha256 = sha256.replaceAll(" ", "");
        if (sha256.length() != 64)
            return false;   // SHA-256は32バイト
        sha256 = sha256.toUpperCase();
        if (sha256.replaceAll("[0-9A-F]+", "").length() != 0)
            return false;   // 0-9A-F 以外の文字がある
        
        mWhitelists.put(pkgname, sha256);
        return true;
    }
    
    public boolean test(Context ctx, String pkgname) {
        // pkgnameに対応する正解のハッシュ値を取得する
        String correctHash = mWhitelists.get(pkgname);

        // pkgnameの実際のハッシュ値と正解のハッシュ値を比較する
        if (Build.VERSION.SDK_INT >= 28) {
            // ★ API Level >= 28 ではPackage ManagerのAPIで直接検証が可能
            PackageManager pm = ctx.getPackageManager();
            return pm.hasSigningCertificate(pkgname,
                                            Utils.hex2Bytes(correctHash),
                                            CERT_INPUT_SHA256);
        } else {
            // API Level < 28 の場合はPkgCertの機能を利用する
            return PkgCert.test(ctx, pkgname, correctHash);
        }
    }
}
PkgCert.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.shared;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.Signature;

public class PkgCert {

    public static boolean test(Context ctx, String pkgname, String correctHash) {
        if (correctHash == null) return false;
        correctHash = correctHash.replaceAll(" ", "");
        return correctHash.equals(hash(ctx, pkgname));
    }

    public static String hash(Context ctx, String pkgname) {
        if (pkgname == null) return null;
        try {
            PackageManager pm = ctx.getPackageManager();
            PackageInfo pkginfo =
                pm.getPackageInfo(pkgname, PackageManager.GET_SIGNATURES);
            if (pkginfo.signatures.length != 1) return null;  // 複数署名は扱わない
            Signature sig = pkginfo.signatures[0];
            byte[] cert = sig.toByteArray();
            byte[] sha256 = computeSha256(cert);
            return byte2hex(sha256);
        } catch (NameNotFoundException e) {
            return null;
        }
    }

    private static byte[] computeSha256(byte[] data) {
        try {
            return MessageDigest.getInstance("SHA-256").digest(data);
        } catch (NoSuchAlgorithmException e) {
            return null;
        }
    }

    private static String byte2hex(byte[] data) {
        if (data == null) return null;
        final StringBuilder hexadecimal = new StringBuilder();
        for (final byte b : data) {
            hexadecimal.append(String.format("%02X", b));
        }
        return hexadecimal.toString();
    }
}

★ポイント11★ Android Studioからメニュー:Build -> Generated Signed APKと選択し、Componentを提供するアプリと同じ開発者鍵で署名する

_images/image68.png

図 5.2.4 Component を提供するアプリと同じ開発者鍵で APK を署名する

Android 9.0 (API Level 28) 以降における署名の検証

Android 9.0 (API Level 28)でAPKの署名スキームV3が導入され、署名用キーのローテーションができるようになった。これと同時にパッケージの署名関連のAPIもアップデートされている [2]。アプリの署名検証の観点から変更点を見ると、PackageManagerクラスの新規メソッドhasSigningCertificate()メソッドを使用して検証できるようになった。具体的には、ガイドのサンプルコードPkgCertクラスが行っていた、検証対象のパッケージから署名に使用した証明書を取得しハッシュ値を計算するなどの処理を代替することができる。上に掲載したサンプルコードの SigPermやPkgCertWhiteListsはこれを反映してAPI Levelが28以上の場合に、この新規メソッドhasSigningCertificate()を用いている。hasSigningCertificate()は署名スキーム違いや複数署名に伴う検査の仕方の違いも吸収してくれるため、API Level 28以降をターゲットとしている場合はこれを用いることをお薦めする [3]

[2]具体的な変更内容については Android Developers サイト (https://developer.android.com/reference/android/content/pm/PackageManager) を参照のこと。
[3]Android 9.0 (API Level 28) の android.content.pm.PackageManager に互換な Android Support Library は本記事を執筆時点でまだ提供されていない。

5.2.1.3. アプリの証明書のハッシュ値を確認する方法

このガイド文書の各所で出てくるアプリの証明書のハッシュ値を確認する方法を紹介する。厳密には「APKを署名するときに使った開発者鍵の公開鍵証明書のSHA256ハッシュ値」を確認する方法である。

Keytoolにより確認する方法

JDKに付属するkeytoolというプログラムを利用すると開発者鍵の公開鍵証明書のハッシュ値(証明書のフィンガープリントとも言う)を求めることができる。ハッシュ値にはハッシュアルゴリズムの違いによりMD5やSHA1、SHA256など様々なものがあるが、このガイド文書では暗号ビット長の安全性を考慮してSHA256の利用を推奨している。残念なことにAndroid SDKで利用されているJDK6に付属するkeytoolはSHA256でのハッシュ値出力に対応しておらず、JDK7以降に付属するkeytoolを使う必要がある。

Androidのデバッグ証明書の内容をkeytoolで出力する例

> keytool -list -v -keystore <キーストアファイル> -storepass <パスワード>

キーストアのタイプ: jks
キーストア・プロバイダ: SUN

キーストアには1エントリが含まれます

別名: androiddebugkey
作成日: 2012/05/18
エントリ・タイプ: PrivateKeyEntry
証明書チェーンの長さ: 1
証明書[1]:
所有者: CN=Android Debug, O=Android, C=US
発行者: CN=Android Debug, O=Android, C=US
シリアル番号: 4fb5d390
有効期間の開始日: Fri May 18 13:44:00 JST 2012 終了日: Tue Oct 04 13:44:00 JST 2039
証明書のフィンガプリント:
         MD5:  8A:1A:E5:15:9A:2A:9A:45:C1:7F:30:EF:17:70:37:D1
         SHA1: 25:BC:25:91:02:A4:DD:04:7D:17:70:EC:41:35:21:00:0C:0A:C7:F1
         SHA256: 0E:FB:72:36:32:83:48:A9:89:71:8B:AD:DF:57:F5:44:D5:CC:B4:AE:B9:DB:34:BC:
                 1E:29:DD:26:F7:7C:82:55
署名アルゴリズム名: SHA1withRSA
サブジェクト公開鍵アルゴリズム: 1024ビットRSA鍵
バージョン: 3


*******************************************
*******************************************
JSSEC証明書ハッシュ値チェッカーにより確認する方法

JDK7以降をインストールしなくても、JSSEC証明書ハッシュ値チェッカーを使えば簡単に証明書ハッシュ値を確認できる。

_images/image69.png

図 5.2.5 JSSEC 証明書ハッシュ値チェッカーによる確認

これは端末にインストールされているアプリの証明書ハッシュ値を一覧表示するAndroidアプリである。上図中、「sha-256」の右に表示されている16進数文字列64文字が証明書ハッシュ値である。このガイド文書と一緒に配布しているサンプルコードの「JSSEC CertHash Checker」フォルダがそのソースコード一式である。ビルドして活用していただきたい。

5.2.1.4. Android 6.0以降でDangerous Permissionを利用する方法

アプリに対するPermission付与のタイミングについて、Android 6.0(API Level 23)でアプリの実装にかかわる仕様変更が行われた。

Android 5.1(API Level 22)以前のPermissionモデル(「5.2.3.6. Android 6.0 以降の Permission モデルの仕様変更について」参照)では、アプリが利用宣言しているPermissionは全てアプリのインストール時に付与される。しかし、Android 6.0以降では、Dangerous Permissionについてはアプリが適切なタイミングでPermissionを要求するよう、アプリ開発者が明示的に実装しなければならない。アプリがPermissionを要求すると、Android OSはユーザーに対して下記のような確認画面を表示し、そのPermissionの利用を許可するかどうかの判断を求めることになる。ユーザーがPermissionの利用を許可すれば、アプリはそのPermissionを必要とする処理を実行することができる。

_images/image70.png

図 5.2.6 Dangerous Permission 利用確認画面

Permissionを付与する単位にも変更が加えられている。従来はすべてのPermissionが一括して付与されていたが、Android 6.0(API Level 23)以降ではPermission Group毎に、Android 8.0(API Level 26)以降ではPermission個別に付与される。これに伴いユーザー確認画面も個別に表示され、ユーザーはPermission利用の可・不可について従来よりも柔軟に判断できるようになった。アプリ開発者は、Permissionの付与が拒否された場合も考慮して、アプリの仕様や設計を見直す必要がある。

Android 6.0以降のPermissionモデルについての詳細は「5.2.3.6. Android 6.0 以降の Permission モデルの仕様変更について」を参照すること。

ポイント:

  1. アプリで利用するPermissionを利用宣言する
  2. 不必要なPermissionは利用宣言しない
  3. Permissionがアプリに付与されているか確認する
  4. Permissionを要求する(ユーザーに許可を求めるダイアログを表示する)
  5. Permissionの利用が許可されていない場合の処理を実装する
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.permission.permissionrequestingpermissionatruntime" >

  <!-- ★ポイント1★ アプリで利用するPermissionを利用宣言する -->
  <!-- 連絡先情報を読み取るPermission (Protection Level: dangerous) -->
  <uses-permission android:name="android.permission.READ_CONTACTS" />

  <!-- ★ポイント2★  不必要なPermissionは利用宣言しない -->

  <application
      android:allowBackup="true"
      android:icon="@mipmap/ic_launcher"
      android:label="@string/app_name"
      android:supportsRtl="true"
      android:theme="@style/AppTheme" >
    <activity
        android:name=".MainActivity"
        android:exported="true">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
    <activity
        android:name=".ContactListActivity"
        android:exported="false">
    </activity>
  </application>

</manifest>
MainActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.permission.permissionrequestingpermissionatruntime;

import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import android.view.View;
import android.widget.Button;
import android.widget.Toast;

public class MainActivity extends AppCompatActivity implements View.OnClickListener {
    private static final int REQUEST_CODE_READ_CONTACTS = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Button button = (Button)findViewById(R.id.button);
        button.setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        readContacts();
    }

    private void readContacts() {
        // ★ポイント3★ Permissionがアプリに付与されているか確認する
        if (ContextCompat.checkSelfPermission(getApplicationContext(),
                                              Manifest.permission.READ_CONTACTS) !=
            PackageManager.PERMISSION_GRANTED) {
            // Permissionが付与されていない
            // ★ポイント4★ Permissionを要求する(ユーザーに許可を求めるダイアログを表示する)
            ActivityCompat.requestPermissions(this,
                new String[]{Manifest.permission.READ_CONTACTS}, REQUEST_CODE_READ_CONTACTS);
        } else {
            // Permissionがすでに付与されている
            showContactList();
        }
    }

    // ユーザー選択の結果を受けるコールバックメソッド
    @Override
    public void onRequestPermissionsResult(int requestCode,
                                           String[] permissions, int[] grantResults) {
        switch (requestCode) {
        case REQUEST_CODE_READ_CONTACTS:
            if (grantResults.length > 0 &&
                grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                // Permissionの利用が許可されているので、連絡先情報を利用する処理を実行できる
                showContactList();
            } else {
                // Permissionの利用が許可されていないため、連絡先情報を利用する処理は実行できない
                // ★ポイント5★ Permissionの利用が許可されていない場合の処理を実装する
                Toast.makeText(this, String.format("連絡先の利用が許可されていません"),
                               Toast.LENGTH_LONG).show();
            }
            return;
        }
    }

    // 連絡先一覧を表示
    private void showContactList() {
        // ContactListActivityを起動
        Intent intent = new Intent();
        intent.setClass(getApplicationContext(), ContactListActivity.class);
        startActivity(intent);
    }
}

5.2.2. ルールブック

独自Permission利用時には以下のルールを守ること。

  1. Android OS規定のDangerous Permissionはユーザーの資産を保護するためにだけ利用する (必須)
  2. 独自定義のDangerous Permissionは利用してはならない (必須)
  3. 独自定義Signature PermissionはComponentの提供側アプリでのみ定義する (必須)
  4. 独自定義Signature Permissionは自社アプリにより定義されていることを確認する (必須)
  5. 独自定義のNormal Permissionは利用してはならない (推奨)
  6. 独自定義のPermission名はアプリのパッケージ名を拡張した文字列にする (推奨)

5.2.2.1. Android OS規定のDangerous Permissionはユーザーの資産を保護するためにだけ利用する (必須)

独自定義のDangerous Permissionの利用は非推奨(「5.2.2.2. 独自定義のDangerous Permissionは利用してはならない (必須)」参照)のため、ここではAndroid OS規定のDangerous Permissionを前提に話をする。

Dangerous Permissionは他の3つのPermissionと異なり、アプリにその権限を付与するかどうかをユーザーに判断を求める機能がある。Dangerous Permissionを利用宣言しているアプリを端末にインストールするとき、次のような画面が表示される。これにより、そのアプリがどのような権限(Dangerous PermissionおよびNormal Permission)を利用しようとしているのかをユーザーが知ることができる。ユーザーが「インストール」をタップすることで、そのアプリは利用宣言した権限が付与され、インストールされるようになっている。

_images/image71.png

図 5.2.7 Android OS 規定の Dangerous Permission 利用確認画面

アプリの中には、ユーザーの資産とアプリ開発者が保護したい資産がある。それらのうち、Dangerous Permissionで保護できるのはユーザーの資産だけであることに注意が必要である。なぜなら、権限の付与がユーザーの判断に委ねられているためである。一方、アプリ開発者が保護したい資産については、この方法では保護できない。

例えば、自社アプリだけと連携するComponentは他社アプリからのアクセスを禁止したい場合を考える。このようなComponentをDangerous Permissionにより保護するように実装したとする。他社アプリがインストールされるときに、ユーザーの判断により他社アプリに対して権限の付与を許可してしまうと、保護すべき自社の資産が他社アプリに悪用される危険が生じる。このような場合に自社の資産を保護するためには、独自定義のSignature Permissionを使うとよい。

5.2.2.2. 独自定義のDangerous Permissionは利用してはならない (必須)

独自定義のDangerous Permissionを使用しても、インストール時に「ユーザーに権限の許可を求める」画面が表示されない場合がある。つまりDangerous Permissionの特徴であるユーザーに判断を求める機能が働かないことがあるのだ。よって本ガイドでは「独自定義のDangerous Permissionを利用しない」ことをルールとする。

まず説明のために2つのアプリを想定する。1つは独自のDangerous Permissionを定義し、このPermissionにより保護したComponentを公開するアプリである。これをProtectedAppとする。もう1つはProtectedAppのComponentを悪用しようとする別のアプリでこれをAttackerAppとする。ここでAttackerAppはProtectedAppが定義したPermissionの利用宣言とともに同じPermissionの定義も行っているものとする。

AttackerAppがユーザーの許可なしにProtectedAppのComponentを利用できてしまうケースは以下のような場合に起きる。

  1. ユーザーがまずAttackerAppをインストールすると、Dangerous Permissionの利用許可を求める画面は表示されずに、そのままインストールが完了してしまう
  2. 次にProtectedAppをインストールすると、ここでも特に警告もなくインストールできてしまう
  3. その後、ユーザーがAttackerAppを起動すると、AttackerAppはユーザーの気づかぬうちにProtectedAppのComponentにアクセスできてしまい、場合によっては被害に繋がる

このケースの原因は次の通りである。先にAttackerAppをインストールしようとするとuses-permissionにより利用宣言されたPermissionはまだその端末上では定義されていない。このときAndroid OSはエラーとすることもなくインストールを続行してしまう。Dangerous Permissionのユーザー確認はインストール時だけしか実施されないため、一度インストールされたアプリは権限を許可されたものとして扱われる。したがって後からインストールされるアプリのComponentを同名のDangerous Permissionで保護していた場合、ユーザーの許可なく先にインストールされたアプリからそのComponentが利用できてしまうのである。

なお、Android OS既定のDangerous Permissionはアプリがインストールされるときにはその存在が保証されているので、uses-permissionしているアプリがインストールされるときには必ずユーザー確認画面が表示される。独自定義のDangerous Permissionの場合にだけこの問題は生じる。

現在、このケースでComponentへのアクセスを防止するよい方法は見つかっていない。したがって、独自定義のDangerous Permissionは利用してはならない。

5.2.2.3. 独自定義Signature PermissionはComponentの提供側アプリでのみ定義する (必須)

自社アプリ間で連携する場合、実行時にSignature Permissionをチェックすることでセキュリティを担保できることを「5.2.1.2. 独自定義のSignature Permissionで自社アプリ連携する方法」で例示した。この仕組みを利用する際には、Protection LevelがSignatureの独自Permissionの定義は、Component提供側アプリのAndroidManifest.xmlでのみ行い、利用側アプリでは独自のSignature Permissionを定義してはならない。

なお、signatureOrSystem Permissionについても同様である。

以下がその理由となる。

提供側アプリより先にインストールされた利用側アプリが複数あり、どの利用側アプリも独自定義Permissionの利用宣言とともにPermissionの定義もしている場合を考える。この状況で提供側アプリをインストールすると、すべての利用側アプリから提供側アプリにアクセスすることが可能になる。次に、最初にインストールした利用側アプリをアンインストールすると、Permissionの定義が削除され、Permissionが未定義となる。そのため、残った利用側アプリからの提供側アプリの利用が不可能となってしまう。

このように、利用側アプリでPermissionの定義を行うと思わぬPermissionの未定義状態が発生するので、Permissionの定義は保護するComponentの提供側アプリのみ行い、利用側アプリでPermissionを定義するのは避けなければならない。

こうすることで、提供側アプリのインストール時に権限付与が行われ、かつ、アンインストール時にPermissionが未定義となり、提供側アプリとPermissionの定義の存在期間が必ず一致するので、適正なComponentの提供と保護が可能である。 なお、独自定義Signature Permissionに関しては、連携するアプリのインストール順によらず、利用側アプリにPermission利用権限が付与されるため、この議論が成り立つことに注意 [4]

[4]Normal/Dangerous Permissionを利用する場合には、Permissionが未定義のまま利用側アプリが先にインストールされると、利用側アプリへの権限の付与が行われず、提供側アプリがインストールされた後もアクセスができない

5.2.2.4. 独自定義Signature Permissionは自社アプリにより定義されていることを確認する (必須)

AnroidManifest.xmlでSignature Permissionを宣言し、ComponentをそのPermissionで保護しただけでは、実は保護が十分ではない。この詳細はアドバンストセクションの「5.2.3.1. 独自定義Signature Permissionを回避できるAndroid OSの特性とその対策」を参照すること。

以下、独自定義Signature Permissionを安全に正しく使う手順である。

まず、AndroidManifest.xmlにて次を行う。

  1. 保護したいComponentのあるアプリのAndroidManifest.xmlにて、独自Signature Permissionを定義する(Permissionの定義)
    例: <permission android:name="xxx" android:protectionLevel="signature" />
  2. 保護したいComponentのあるAndroidManifest.xmlにて、そのComponentの定義タグのpermission属性で、独自定義Signature Permissionを指定する(Permissionの要求宣言)
    例: <activity android:permission="xxx" ... >...</activity>
  3. 保護したいComponentにアクセスする連携アプリのAndroidManifest.xmlにて、uses-permissionタグに独自定義Signature Permissionを指定する(Permissionの利用宣言)
    例: <uses-permission android:name="xxx" />

続いて、ソースコード上にて次を実装する。

  1. 保護したいComponentでリクエストを処理する前に、独自定義したSignature Permissionが自社アプリにより定義されたものかどうかを確認し、そうでなければリクエストを無視する(Component提供側による保護)
  2. 保護したいComponentにアクセスする前に、独自定義したSignature Permissionが自社アプリにより定義されたものかどうかを確認し、そうでなければComponentにアクセスしない(Component利用側による保護)

最後にAndroid Studioの署名機能にて次を行う。

  1. 連携するすべてのアプリのAPKを同じ開発者鍵で署名する

ここで「独自定義したSignature Permissionが、自社アプリにより定義されたものかどうかを確認」する必要があるが、具体的な実装方法についてはサンプルコードセクションの「5.2.1.2. 独自定義のSignature Permissionで自社アプリ連携する方法」を参照すること。

なお、signatureOrSystem Permissionについても同様である。

5.2.2.5. 独自定義のNormal Permissionは利用してはならない (推奨)

Normal Permissionを利用するアプリはAndroid Manifest.xmlにuses-permissionで利用宣言するだけでその権限を得ることができる。そのため、一度インストールされてしまったマルウェアからComponentを保護するような目的にNormal Permissionは利用できない。

さらに独自定義Normal Permissionを用いてアプリ間連携を行う場合、連携する各アプリへのPermissionの付与はインストール順に依存する。例えば、Permissionを定義したコンポーネントを持つアプリよりも先にそのPermissionを利用宣言したアプリをインストールすると、Permissionを定義したアプリをインストールした後も利用側アプリはPermissionで保護されたコンポーネントにアクセスすることができない。

インストール順によりアプリ間連携ができなくなる問題を回避する方法として、連携する全てのアプリにPermissionを定義することも考えられる。そうすることにより最初に利用側アプリがインストールされた場合でも、全ての利用側アプリが提供側アプリにアクセスすることが可能となる。しかし、最初にインストールした利用側アプリがアンインストールされた際にPermissionが未定義な状態となり、他に利用側アプリが存在していても、それらのアプリから提供側アプリにアクセスすることができなくなってしまうのである。

以上のようにアプリの可用性が損なわれる恐れがあることから、独自定義Normal Permissionの利用は控えるべきである。

5.2.2.6. 独自定義のPermission名はアプリのパッケージ名を拡張した文字列にする (推奨)

複数のアプリが同じ名前でPermissionを定義する場合、先にインストールされたアプリが定義するProtection Levelが適用される。先にインストールされたアプリがNormal Permissionを定義し、後にインストールされたアプリが同じ名前でSignature Permissionを定義した場合、Signature Permissionによる保護がまったく効かない。悪意がない場合でも、複数のアプリにおいてPermission名が衝突して意図しないProtection Levelで動作する可能性がある。このような事故を防ぐため、Permission名にはアプリのパッケージ名を入れた方が良い。

(パッケージ名).permission.(識別する文字列)

例えば、org.jssec.android.sampleというパッケージにREADアクセスのPermissionを定義するならば、次の様な命名が好ましい。

org.jssec.android.sample.permission.READ

5.2.3. アドバンスト

5.2.3.1. 独自定義Signature Permissionを回避できるAndroid OSの特性とその対策

独自定義Signature Permissionは、同じ開発者鍵で署名されたアプリ間だけでアプリ間連携を実現するPermissionである。開発者鍵はプライベート鍵であり絶対に公開してはならないものであるため、Signature Permissionによる保護は自社アプリだけで連携する場合に使われることが多い。

まずは、AndroidのDev Guide(https://developer.android.com/guide/topics/security/security.html)で説明されている独自定義Signature Permissionの基本的な使い方を紹介する。ただし、後述するように、この使い方にはPermission回避の問題があることが分かっており、本ガイドに掲載した対策が必要となる。

以下、独自定義Signature Permissionの基本的な使い方である。

  1. 保護したいComponentのあるアプリのAndroidManifest.xmlにて、独自Signature Permissionを定義する
    例: <permission android:name="xxx" android:protectionLevel="signature" />
  2. 保護したいComponentを持つアプリのAndroidManifest.xmlで、保護したいComponentにandroid:permission属性を指定し、1.で定義したSignature Permissionを要求する
    例: <activity android:permission="xxx" ... >...</activity>
  3. 保護したいComponentにアクセスしたい連携アプリのAndroidManifest.xmlにて、独自定義Signature Permissionを利用宣言する
    例: <uses-permission android:name="xxx" />
  4. 連携するすべてのアプリのAPKを同じ開発者鍵で署名する

実は、この使い方だけでは、次の条件が成立するとSignature Permission回避の抜け道ができてしまう。

説明のために独自定義のSignature Permissionで保護したアプリをProtectedAppとし、ProtectedAppとは異なる開発者鍵で署名したアプリをAttackerAppとする。 ここでSignature Permission回避の抜け道とは、AttackerAppは署名が一致していないにもかかわらず、ProtectedAppのComponentにアクセス可能になることである。

条件1. AttackerAppもProtectedAppが独自定義したSignature Permissionと同じ名前でNormal Permissionを定義する(厳密にはSignature ​ Permissionでも構わない)
例: <permission android:name=" xxx " android:protectionLevel="normal" />

条件2. AttackerAppは独自定義したNormal Permissionをuses-permissionで利用宣言する
例: <uses-permission android:name="xxx" />

条件3. AttackerAppをProtectedAppより先に端末にインストールする

_images/image72.png

図 5.2.8 Signature Permission 回避の抜け道

条件1および条件2の成立に必要なProtectedApp独自定義のPermission名は、APKファイルからAndroidManifest.xmlを取り出せば攻撃者にとって容易に知ることができる。条件3もユーザーを騙すなどの方法により攻撃者にある程度制御の余地がある。

このように独自定義のSignature Permissionには基本的な使い方だけでは保護を回避されてしまう危険性があり、抜け道をふさぐような対策が必要である。具体的にはルールセクションの「5.2.2.4. 独自定義Signature Permissionは自社アプリにより定義されていることを確認する (必須)」に掲載している方法で対処できるので、そちらを参照のこと。

5.2.3.2. ユーザーがAndroidManifest.xmlを改ざんする

独自PermissionのProtection Levelが意図しないものになるケースは既に説明した。そのことによる不具合を防ぐために、Javaのソースコード側で何らかの対応を実施する必要があった。ここでは、AndroidManifest.xmlが改ざんされるという視点から、ソースコード側の対応について述べる。改ざんを検知する簡易な実装例を提示するが、犯罪意識をもって改ざんを行うプロのハッカーに対してはほとんど効果がない方法であることに注意すること。

この節はアプリの改ざんに関するものであり、ユーザー自身が悪意を持っているケースである。本来はガイドラインの範囲外であるが、Permissionに関する事、これを行うツールがアプリとして公開されている事、から「プロでないハッカーに対する簡易な対策」として述べておくことにした。

Androidアプリは、root権限無しに改ざんできることを頭に置いておく必要がある。なぜなら、AndroidManifest.xmlを変更してAPKファイルを再生成、署名するツールが配布されているためである。このツールを使用する事で、誰でも任意のアプリからPermissionを削除することが可能になっている。

事例としてはINTERNET Permissionを取り除いたAndroidManifest.xmlから別署名のAPKを生成し、アプリに組み込まれた広告モジュールが動作しないようにするケースが多いようである。個人情報がどこかに送信されているかもしれない等の不安が払拭されるということで、この種のツールの存在を評価しているユーザーも存在する。このような行為は、アプリに組み込まれた広告が機能しなくなるため、広告収入を期待している開発者に対して金銭的被害を与える行動であるとも言える。ユーザーのほとんどは罪の意識無くこれらの行為を行っていると思われる。

インターネットPermissionをuses-permissionで宣言しているアプリが、実行時に自身のAndroidManifest.xmlに記載されているPermissionを確認する実装例を次に示す。

public class CheckPermissionActivity extends Activity {
    
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        
        // AndroidManifest.xmlに定義したPermissionを取得
        List<String> list = getDefinedPermissionList();
        
        // 改ざんを検知する
        if( checkPermissions(list) ){
            // OK
            Log.d("dbg", "OK.");
        }else{
            Log.d("dbg", "manifest file is stale.");
            finish();
        }
    }

    /**
     * AndroidManifest.xmlに定義したPermissionをリストで取得する
     * @return
     */
    private List<String> getDefinedPermissionList(){
        List<String> list = new ArrayList<String>();
        list.add("android.permission.INTERNET");
        return list;
    }
    
    /**
     * Permissionが変更されていないことを確認する。
     * @param permissionList
     * @return
     */
    private boolean checkPermissions(List<String> permissionList){
        try {
            PackageInfo packageInfo = getPackageManager().getPackageInfo(
                    getPackageName(), PackageManager.GET_PERMISSIONS);
            String[] permissionArray = packageInfo.requestedPermissions;
            if (permissionArray != null) {
                for (String permission : permissionArray) {
                    if(! permissionList.remove(permission)){
                        // 意図しないPermissionが付加されている
                        return false;
                    }
                }
            }
            
            if(permissionList.size() == 0){
                // OK
                return true;
            }
            
        } catch (NameNotFoundException e) {
        }
        
        return false;
    }
}

5.2.3.3. APKの改ざんを検出する

5.2.3.2. ユーザーがAndroidManifest.xmlを改ざんする」ではユーザーによるPermission改ざんの検出について説明した。しかし、アプリの改ざんはPermissionに限らず、リソースを差し替えて別のアプリとしてマーケットで配布するなど、ソースコードを変更することなく改ざんし流用する事例が多様に存在する。ここではAPKファイルが改ざんされたことを検出するためのより汎用的な方法を紹介する。

APKの改ざんを行うには、APKファイルを一度展開し、内容を改変した後に再びAPKファイルとして再構成する必要がある。その際に改ざん者は元の開発者の鍵を持ち得ないので、改ざん者自身の鍵でAPKを署名することになる。このようにAPKの改ざんには署名(証明書)の変更を伴うため、アプリ起動時にAPKの証明書と予めソースコードに埋め込んだ開発者の証明書を比較することで改ざんの有無を検出することができる。

以下にサンプルコードを示す。なお、実装例のままではプロのハッカーであれば改ざん検出の無効化が容易である。あくまで簡易な実装例であることを念頭においてアプリへの適用を検討するべきである。

ポイント:

  1. 主要な処理を行うまでの間に、アプリの証明書が開発者の証明書であることを確認する
SignatureCheckActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.permission.signcheckactivity;

import org.jssec.android.shared.PkgCert;
import org.jssec.android.shared.Utils;

import android.app.Activity;
import android.content.Context;
import android.os.Bundle;
import android.widget.Toast;

public class SignatureCheckActivity extends Activity {
    // 自己証明書のハッシュ値
    private static String sMyCertHash = null;
    private static String myCertHash(Context context) {
        if (sMyCertHash == null) {
            if (Utils.isDebuggable(context)) {
                // debug.keystoreの"androiddebugkey"の証明書ハッシュ値
                sMyCertHash = "0EFB7236 328348A9 89718BAD DF57F544 D5CCB4AE B9DB34BC 1E29DD26 F77C8255";
            } else {
                // keystoreの"my company key"の証明書ハッシュ値
                sMyCertHash = "D397D343 A5CBC10F 4EDDEB7C A10062DE 5690984F 1FB9E88B D7B3A7C2 42E142CA";
            }
        }
        return sMyCertHash;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        // ★ポイント1★ 主要な処理を行うまでの間に、アプリの証明書が開発者の証明書であることを確認する
        if (!PkgCert.test(this, this.getPackageName(), myCertHash(this))) {
            Toast.makeText(this, "自己署名の照合 NG", Toast.LENGTH_LONG).show();
            finish();
            return;
        }
        Toast.makeText(this, "自己署名の照合 OK", Toast.LENGTH_LONG).show();
    }
}
PkgCert.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.shared;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.Signature;

public class PkgCert {

    public static boolean test(Context ctx, String pkgname, String correctHash) {
        if (correctHash == null) return false;
        correctHash = correctHash.replaceAll(" ", "");
        return correctHash.equals(hash(ctx, pkgname));
    }

    public static String hash(Context ctx, String pkgname) {
        if (pkgname == null) return null;
        try {
            PackageManager pm = ctx.getPackageManager();
            PackageInfo pkginfo =
                pm.getPackageInfo(pkgname, PackageManager.GET_SIGNATURES);
            if (pkginfo.signatures.length != 1) return null;  // 複数署名は扱わない
            Signature sig = pkginfo.signatures[0];
            byte[] cert = sig.toByteArray();
            byte[] sha256 = computeSha256(cert);
            return byte2hex(sha256);
        } catch (NameNotFoundException e) {
            return null;
        }
    }

    private static byte[] computeSha256(byte[] data) {
        try {
            return MessageDigest.getInstance("SHA-256").digest(data);
        } catch (NoSuchAlgorithmException e) {
            return null;
        }
    }

    private static String byte2hex(byte[] data) {
        if (data == null) return null;
        final StringBuilder hexadecimal = new StringBuilder();
        for (final byte b : data) {
            hexadecimal.append(String.format("%02X", b));
        }
        return hexadecimal.toString();
    }
}

5.2.3.4. Permissionの再委譲問題

アプリがAndroid OSに保護されている電話帳やGPSといった情報や機能にアクセスするためにはPermissionを利用宣言しなければならない。Permissionを利用宣言し許可されると、そのアプリにはそのPermissionが委譲されたことになり、そのPermissionにより保護された情報や機能にアクセスできるようになる。

プログラムの組み方によっては、Permissionを委譲された(許可された)アプリはPermissionで保護されたデータを取得し、そのデータを別のアプリに何のPermissionも要求せずに提供することもできてしまう。これはPermissionを持たないアプリがPermissionで保護されたデータにアクセスできることに他ならない。実質的にPermissionを再委譲していることと等価になるので、これをPermissionの再委譲問題と呼ぶ。このようにAndroidのPermissionセキュリティモデルでは、保護されたデータへのアプリからの直接アクセスだけしか権限管理ができないという仕様上の性質がある。

具体例を 図 5.2.9 に示す。中央のアプリはandroid.permission.READ_CONTACTSを利用宣言したアプリが連絡先情報を読み取って自分のDBに蓄積している。何の制限もなくContent Provider経由で蓄積した情報を他のアプリに提供した場合に、Permissionの再委譲問題が生じる。

_images/image73.png

図 5.2.9 Permission を持たないアプリが連絡先情報を取得する

同様の例として、android.permission.CALL_PHONEを利用宣言したアプリが、同Permissionを利用宣言していない他のアプリからの任意の電話番号を受け付け、ユーザーの確認もなくその番号に電話を掛けることができるならば、Permissionの再委譲問題がある。

Permissionの利用宣言をして得た情報資産・機能資産をほぼそのままの形で他のアプリに二次提供する場合には、提供先アプリに対し同じPermissionを要求するなどして、元の保護水準を維持しなければならない。また情報資産・機能資産の一部分のみを他のアプリに二次提供する場合には、その情報資産・機能資産の一部分が悪用されたときの被害度合に応じた適切な保護が必要である。たとえば前述と同様に同じPermissionを要求したり、ユーザーへ利用許諾を確認したり、「4.1.1.1. 非公開Activityを作る・利用する」「4.1.1.4. 自社限定Activityを作る・利用する」などを利用して対象アプリの制限を設けるなどの保護施策がある。

このような再委譲問題はPermissionに限ったことではない。Androidアプリでは、アプリに必要な情報・機能を他のアプリやネットワーク・記憶媒体から調達することが一般に行われている。提供元がAndroidアプリであればPermission、ネットワークであればログイン、記憶媒体であればアクセス制限といったように、それぞれ調達する際に必要な権限や制限が存在することも多い。こうして調達した情報や機能をその所有者であるユーザーから二次的に他のアプリに提供したり、ネットワークや記憶媒体に転送する際には、ユーザーの意図に反した利用がないように慎重に検討してアプリに対策を施すべきである。必要に応じて、Permissionの例と同様に提供先に対して権限の要求や使用の制限を行わなければならない。ユーザーへの利用許諾もその一環である。

以下では、READ_CONTACTS Permissionを利用して連絡先DBから一覧を取得したアプリが、情報提供先のアプリに対して同じREAD_CONTACTS Permissionを要求する例を示す。

ポイント:

  1. Manifestで提供元と同じPermissionを要求する
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.permission.transferpermission" >
  
  <uses-permission android:name="android.permission.READ_CONTACTS"/>
  
  <application
      android:allowBackup="false"
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name"
      android:theme="@style/AppTheme" >
    <activity
        android:name=".TransferPermissionActivity"
        android:label="@string/title_activity_transfer_permission" >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
    
    <!-- ★ポイント1★ Manifestで提供元と同じPermissionを要求する -->
    <provider
        android:name=".TransferPermissionContentProvider"
        android:authorities="org.jssec.android.permission.transferpermission"
        android:enabled="true"
        android:exported="true"
        android:readPermission="android.permission.READ_CONTACTS" >
    </provider>
  </application>
  
</manifest>

アプリが複数のPermissionを要求する必要がある場合は、上記の方法では解決することができない。ソースコード上でContext#checkCallingPermission()やPackageManager#checkPermission()を使用して、呼び出し元のアプリがManifestですべてのPermissionの利用宣言を行っているかどうかを確認することになる。

Activityの場合

public void onCreate(Bundle savedInstanceState) {
// ~省略~
    if (checkCallingPermission("android.permission.READ_CONTACTS") ==
            PackageManager.PERMISSION_GRANTED
        && checkCallingPermission("android.permission.WRITE_CONTACTS") ==
            PackageManager.PERMISSION_GRANTED) {
        // 呼び出し元が正しくPermissionを利用宣言していた時の処理
         return;
    }
    finish();
}

5.2.3.5. 独自定義Permissionの署名チェック機構について (Android 5.0以降)

Android 5.0(API Level 21)以降の端末では、独自のPermissionを定義したアプリにおいて以下のような条件に合致するとインストールに失敗するように仕様変更された。

  1. 既に同名のPermissionを定義したアプリがインストールされている
  2. そのインストール済みのアプリと署名が一致しない

この仕様変更により、保護対象の機能 (Component)の提供側アプリと利用側アプリの双方でPermissionを定義した場合には、同じPermissionを定義した署名の異なる他社アプリが両アプリと同時にインストールされるのを防ぐことができる。しかしながら、「5.2.2.3. 独自定義Signature PermissionはComponentの提供側アプリでのみ定義する (必須)」で言及した通り、アプリのアンインストール操作などによって、双方のアプリにPermissionを定義するとそのPermissionが意図せず未定義状態になる場合があるため、この仕様を自社の定義したSignature Permissionが他アプリに定義されていないことのチェックに活用することはできないことが分かっている。

結果として、自社限定アプリで独自定義Signature Permissionを利用する場合は、引き続き「5.2.2.3. 独自定義Signature PermissionはComponentの提供側アプリでのみ定義する (必須)」、「5.2.2.4. 独自定義Signature Permissionは自社アプリにより定義されていることを確認する (必須)」のルールを順守する必要がある。

5.2.3.6. Android 6.0 以降の Permission モデルの仕様変更について

Android 6.0(API Level 23)においてアプリの仕様や設計にも影響を及ぼすPermissionモデルの仕様変更が行われた。本節ではAndroid 6.0以降のPermissionモデルの概要を解説する。またAndroid 8.0以降での変更点についても記載する。

権限の付与・取り消しのタイミング

ユーザー確認が必要な権限(Dangerous Permission)をアプリが利用宣言している場合(「5.2.2.1. Android OS規定のDangerous Permissionはユーザーの資産を保護するためにだけ利用する (必須)」参照)、Android 5.1(API Level 22)以前の仕様では、アプリのインストール時にその権限一覧が表示され、ユーザーがすべての権限を許可することでインストールが行われる。この時点で、アプリが利用宣言している(Dangerous Permission以外の権限を含め)全ての権限はアプリに付与され、一度付与された権限はアプリが端末からアンインストールされるまで有効である。 しかしAndroid 6.0以降の仕様では、権限の付与はアプリの実行時に行う仕様となり、アプリのインストール時には権限の付与もユーザーへの確認も行われない。アプリは、Dangerous Permissionを必要とする処理を実行する際、事前にその権限がアプリに付与されているかどうかを確認し、権限が付与されていない場合にはAndroid OSに確認画面を表示させ、ユーザーに権限利用の許可を求める必要がある [5]。ユーザーが確認画面で許可することでその権限はアプリに付与される。ただし、ユーザーは一度アプリに許可した権限(Dangerous Permission)を、設定メニューを通じて任意のタイミングで取り消すことができる(図 5.2.10)ため、権限がアプリに付与されておらず必要な情報や機能にアクセスすることができない状況においても、アプリが異常な動作を起こすことがないよう適切な処理を実装する必要がある。

[5]Normal PermissionおよびSignature PermissionはAndroid OSにより自動的に付与されるため、ユーザー確認を行う必要はない。
_images/image74.png

図 5.2.10 アプリの権限設定画面

権限の付与・取り消しの単位

いくつかのPermissionはその機能や関連する情報の種類に応じて、Permission Groupと呼ばれる単位でグループ化されている。例えば、カレンダー情報の読み取りに必要なPermissionであるandroid.permission.READ_CALENDARと、カレンダー情報の書き込みに必要なPermissionであるandroid.permission.WRITE_CALENDARは、どちらもandroid.permission-group.CALENDARというPermission Groupに属している。

Android 6.0(API Level 23)以降のPermissionモデルにおいて、権限の付与や取り消しはこのPermission Groupを単位として行われる。ただし、OSとSDKのバージョンの組み合わせによってこの単位が変わるので注意が必要となる(下記参照)。

  • 端末:Android 6.0(API Level 23)以降、アプリのtargetSdkVersion:23~25の場合

Manifestにandroid.permission.READ_CALENDARとandroid.permission.WRITE_CALENDARが記載されている状態で、アプリの実行時にandroid.permission.READ_CALENDARの要求が行われ、ユーザーがこれを許可すると、Android OSはandroid.permission.READ_CALENDARとandroid.permission.WRITE_CALENDARの利用が両方とも許可されたとみなし権限が付与される。

  • 端末:Android 8.0(API Level 26)以降、アプリのtargetSdkVersion:26以上の場合

要求したPermissionの権限のみが付与される。つまりManifestにandroid.permission.READ_CALENDARとandroid.permission.WRITE_CALENDARが記載されていても、android.permission.READ_CALENDARのみを要求しユーザーに許可されたのならandroid.permission.READ_CALENDARの権限のみが付与される。ただし、その後android.permission.WRITE_CALENDARが要求された場合は、ユーザーに確認ダイアログが表示されることなく即時に権限が付与される [6]

[6]この場合も、アプリによるandroid.permission.READ_CALENDARとandroid.permission.WRITE_CALENDARの利用宣言はともに必要である。

また、権限の付与とは異なり、設定メニューからの権限の取り消しはAndroid 8.0以降でもPermission Group単位で行われる。 Permission Groupの分類についてはDeveloper Reference( https://developer.android.com/intl/ja/guide/topics/security/permissions.html#perm-groups)を参照すること。

仕様変更の影響範囲

アプリの実行時にPermission要求が必要なのは、端末がAndroid 6.0以降で動作していることに加え、アプリのtargetSdkVersionが23以上に設定されている場合に限られる。端末がAndroid 5.1以前で動作している場合や、アプリのtargetSdkVersionが23未満である場合、権限は従来通りアプリのインストール時にまとめて付与される。ただし、アプリのtargetSDKVersionが23未満であっても、端末がAndroid 6.0(API Level 23)以降であれば、インストールされたアプリのPermissionをユーザーが任意のタイミングで取り消すことができるため、意図しないアプリの異常終了が起きる可能性がある。早急に仕様変更に対応するか、アプリのmaxSdkVersionを22以前に設定して、Android 6.0(API Level 23)以降の端末にイントールされないようにするなどの対応が必要である。

また、Android 10 の端末では、Android 5.1(API Level 22)以前の端末をターゲットにしているアプリを初めて実行したときに、正常に動作しない可能性がある旨の警告が表示される。更に、ユーザーにストレージアクセスなどの権限の付与を求めるアプリの場合、この警告の前に権限(許可する/許可しない)選択画面が表示される [7]

[7]https://developer.android.com/preview/behavior-changes-all#low-target-sdk-warnings
表 5.2.1 アプリへの権限付与のタイミング
端末のAndroid OSバージョン アプリのtargetSDKVersion アプリへの権限付与のタイミング ユーザーによる権限制御
≧8.0 ≧26 アプリ実行時(付与はPermission単位) あり
<26 アプリ実行時(付与はGroup単位) あり
<23 インストール時 あり(早急な対応が必要)
≧6.0 ≧23 アプリ実行時(付与はGroup単位) あり
<23 インストール時 あり(早急な対応が必要)
≦5.1 ≧23 インストール時 なし
<23 インストール時 なし

ただし、maxSdkVersion の効果は限定的であることに注意が必要である。maxSdkVersionを22以前に設定した場合、アプリをGoogle Play経由で配布したときには、Android 6.0(API Level 23)以降の端末が対象アプリのインストール可能端末としてリスト表示されなくなる。一方、Google Play以外のマーケットプレイスではmaxSdkVersionの値がチェックされないことがあるため、Android 6.0(API Level 23)以降の端末に対象アプリをインストールできる場合がある。

このようにmaxSdkVersionの効果は限定的であること、さらにGoogleがmaxSdkVersionの使用を推奨していないことを踏まえ、早急に仕様変更に対応することをお勧めする。

なお、以下のネットワーク通信に関するPermissionは、Android 6.0(API Level 23)以降Protection Levelがdangerousからnormalに変更されている。つまり、これらのPermissionは利用宣言していても、ユーザーの明示的な許可を必要としないため、今回の仕様変更の影響を受けない。

  • android.permission.BLUETOOTH
  • android.permission.BLUETOOTH_ADMIN
  • android.permission.CHANGE_WIFI_MULTICAST_STATE
  • android.permission.CHANGE_WIFI_STATE
  • android.permission.CHANGE_WIMAX_STATE
  • android.permission.DISABLE_KEYGUARD
  • android.permission.INTERNET
  • android.permission.NFC

5.3. Account Managerに独自アカウントを追加する

Account Managerはアプリがオンラインサービスへアクセスするために必要となるアカウント情報(アカウント名、パスワード)および認証トークンを一元管理するAndroid OSの仕組みである [8]。ユーザーは事前にアカウント情報をAccount Managerに登録しておき、アプリがオンラインサービスにアクセスしようとしたときにユーザーの許可を得て、Account Managerがアプリに認証トークンを自動提供する仕組みである。パスワードという極めてセンシティブな情報をアプリが扱わなくて済むことがAccount Managerの利点である。

[8]Account Managerはオンラインサービスとの同期の仕組みも提供するが、本節では扱っていない。

Account Managerを使用したアカウント管理機能は 図 5.3.1 のような構成となる。「利用アプリ」は認証トークンの提供を受けてオンラインサービスにアクセスするアプリであり、前述のアプリのことである。一方、「Authenticatorアプリ」はAccount Managerの機能拡張であり、Authenticatorと呼ばれるオブジェクトをAccount Managerに提供することにより、Account Managerがそのオンラインサービスのアカウント情報および認証トークンを一元管理できるようになる。利用アプリとAuthenticatorアプリは別のアプリである必要はなく、一つのアプリとして実装することもできる。

_images/image75.png

図 5.3.1 Manager を使用したアカウント管理機能の構成

本来、利用アプリとAuthenticatorアプリは開発者の署名鍵が異なっていてもよい。しかしAndroid 4.0.x の端末に限りAndroid Frameworkのバグがあり、利用アプリとAuthenticatorアプリの署名鍵が異なっていると利用アプリで例外が発生してしまい、独自アカウントが利用できない。ここで紹介するサンプルコードはこの不具合には対応できていない。詳しくは「5.3.3.2. Android 4.0.xでは利用アプリとAuthenticatorアプリの署名鍵が異なると例外が発生する」を参照すること。

5.3.1. サンプルコード

Authenticatorアプリのサンプルとして「5.3.1.1. 独自アカウントを作る」を、利用アプリのサンプルとして「5.3.1.2. 独自アカウントを利用する」を用意した。JSSECのWebサイトで配布しているサンプルコード一式ではそれぞれAccountManager AuthenticatorおよびAccountManager Userに対応している。

5.3.1.1. 独自アカウントを作る

ここではAccount Managerが独自アカウントを扱えるようにするAuthenticatorアプリのサンプルコードを紹介する。このアプリはホーム画面から起動できるActivityは存在しない。もう一つのサンプルアプリ「5.3.1.2. 独自アカウントを利用する」からAccount Manager経由で間接的に呼び出されることに注意してほしい。

ポイント:

  1. Authenticatorを提供するServiceは非公開Serviceとする
  2. ログイン画面ActivityはAuthenticatorアプリで実装する
  3. ログイン画面Activityは公開Activityとして他のアプリからの攻撃アクセスを想定する
  4. KEY_INTENTには、ログイン画面Activityのクラス名を指定した明示的Intentを与える
  5. アカウント情報や認証トークンなどのセンシティブな情報はログ出力しない
  6. Account Managerにパスワードを保存しない
  7. Authenticatorとオンラインサービスとの通信はHTTPSで行う

AndroidManifest.xmlにてAuthenticatorのIBinderをAccount Managerに提供するサービスを定義。meta-dataにてAuthenticatorを記述したリソースXMLファイルを指定。

AccountManager Authenticator/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.accountmanager.authenticator"
          xmlns:tools="http://schemas.android.com/tools">

  <!-- Authenticatorを実装するのに必要なPermission -->
  <uses-permission android:name="android.permission.GET_ACCOUNTS" />
  <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />

  <application
      android:allowBackup="false"
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name" >

    <!-- AuthenticatorのIBinderをAccountManagerに提供するサービス -->
    <!-- ★ポイント1★ Authenticatorを提供するServiceは非公開Serviceとする -->
    <service
        android:name=".AuthenticationService"
        android:exported="false" >
      <!-- intent-filterとmeta-dataはお決まりのパターン -->
      <intent-filter>
        <action android:name="android.accounts.AccountAuthenticator" />
      </intent-filter>
      <meta-data
          android:name="android.accounts.AccountAuthenticator"
          android:resource="@xml/authenticator" />
    </service>

    <!-- アカウントを追加するときなどに表示されるログイン画面用のActivity -->
    <!-- ★ポイント2★ ログイン画面ActivityはAuthenticatorアプリで実装する -->
    <!-- ★ポイント3★ ログイン画面Activityは公開Activityとして他のアプリからの攻撃アクセスを想定する -->
    <activity
        android:name=".LoginActivity"
        android:exported="true"
        android:label="@string/login_activity_title"
        android:theme="@android:style/Theme.Dialog"
        tools:ignore="ExportedActivity" />
  </application>

</manifest>

XMLファイルでAuthenticatorを定義。独自アカウントのアカウントタイプ等を指定する。

res/xml/authenticator.xml
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
    android:accountType="org.jssec.android.accountmanager"
    android:icon="@drawable/ic_launcher"
    android:label="@string/label"
    android:smallIcon="@drawable/ic_launcher"
    android:customTokens="true" />

AuthenticatorのインスタンスをAccount Managerに提供するサービス。このサンプルで実装するAuthenticatorであるJssecAuthenticatorクラスのインスタンスをonBind()でreturnするだけの簡単な実装でよい。

AuthenticationService.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.accountmanager.authenticator;

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;

public class AuthenticationService extends Service {

    private JssecAuthenticator mAuthenticator;

    @Override
    public void onCreate() {
        mAuthenticator = new JssecAuthenticator(this);
    }

    @Override
    public IBinder onBind(Intent intent) {
        return mAuthenticator.getIBinder();
    }
}

このサンプルで実装するAuthenticatorであるJssecAuthenticator。AbstractAccountAuthenticatorを継承してabstractメソッドをすべて実装する。これらのメソッドはAccount Managerから呼ばれる。addAccount()およびgetAuthToken()では、オンラインサービスから認証トークンを取得するためのLoginActivityを起動するintentをAccount Managerに返している。

JssecAuthenticator.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.accountmanager.authenticator;

import android.accounts.AbstractAccountAuthenticator;
import android.accounts.Account;
import android.accounts.AccountAuthenticatorResponse;
import android.accounts.AccountManager;
import android.accounts.NetworkErrorException;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;

public class JssecAuthenticator extends AbstractAccountAuthenticator {

    public static final String JSSEC_ACCOUNT_TYPE = "org.jssec.android.accountmanager";
    public static final String JSSEC_AUTHTOKEN_TYPE = "webservice";
    public static final String JSSEC_AUTHTOKEN_LABEL = "JSSEC Web Service";
    public static final String RE_AUTH_NAME = "reauth_name";
        
    protected final Context mContext;

    public JssecAuthenticator(Context context) {
        super(context);
        mContext = context;
    }

    @Override
    public Bundle addAccount(AccountAuthenticatorResponse response, String accountType,
                             String authTokenType, String[] requiredFeatures,
                             Bundle options)
            throws NetworkErrorException {

        AccountManager am = AccountManager.get(mContext);
        Account[] accounts = am.getAccountsByType(JSSEC_ACCOUNT_TYPE);
        Bundle bundle = new Bundle();
        if (accounts.length > 0) {
            // 本サンプルコードではアカウントが既に存在する場合はエラーとする
            bundle.putString(AccountManager.KEY_ERROR_CODE, String.valueOf(-1));
            bundle.putString(AccountManager.KEY_ERROR_MESSAGE,
                             mContext.getString(R.string.error_account_exists));
        } else {
            // ★ポイント2★ ログイン画面ActivityはAuthenticatorアプリで実装する
            // ★ポイント4★ KEY_INTENTには、ログイン画面Activityのクラス名を指定した明示的Intentを与える
            Intent intent = new Intent(mContext, LoginActivity.class);
            intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);

            bundle.putParcelable(AccountManager.KEY_INTENT, intent);
        }
        return bundle;
    }

    @Override
    public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account,
                               String authTokenType, Bundle options)
            throws NetworkErrorException {

        Bundle bundle = new Bundle();
        if (accountExist(account)) {
            // ★ポイント4★ KEY_INTENTには、ログイン画面Activityのクラス名を指定した明示的Intentを与える
            Intent intent = new Intent(mContext, LoginActivity.class);
            intent.putExtra(RE_AUTH_NAME, account.name);
            intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
            bundle.putParcelable(AccountManager.KEY_INTENT, intent);
        } else {
            // 指定されたアカウントが存在しない場合はエラーとする
            bundle.putString(AccountManager.KEY_ERROR_CODE, String.valueOf(-2));
            bundle.putString(AccountManager.KEY_ERROR_MESSAGE,
                             mContext.getString(R.string.error_account_not_exists));
        }
        return bundle;
    }

    @Override
    public String getAuthTokenLabel(String authTokenType) {
        return JSSEC_AUTHTOKEN_LABEL;
    }

    @Override
    public Bundle confirmCredentials(AccountAuthenticatorResponse response,
                                     Account account, Bundle options)
            throws NetworkErrorException {
        return null;
    }

    @Override
    public Bundle editProperties(AccountAuthenticatorResponse response,
                                 String accountType) {
        return null;
    }

    @Override
    public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account,
                                    String authTokenType, Bundle options)
            throws NetworkErrorException {
        return null;
    }

    @Override
    public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account,
                              String[] features)
            throws NetworkErrorException {
        Bundle result = new Bundle();
        result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false);
        return result;
    }

    private boolean accountExist(Account account) {
        AccountManager am = AccountManager.get(mContext);
        Account[] accounts = am.getAccountsByType(JSSEC_ACCOUNT_TYPE);
        for (Account ac : accounts) {
            if (ac.equals(account)) {
                return true;
            }
        }
        return false;
    }
}

オンラインサービスにアカウント名、パスワードを送信してログイン認証を行い、その結果として認証トークンを取得するLoginActivity。新規アカウント追加および認証トークン再取得の場合に表示される。オンラインサービスへの実際のアクセスはWebServiceクラス内で実装されるものとしている。

LoginActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.accountmanager.authenticator;

import org.jssec.android.accountmanager.webservice.WebService;

import android.accounts.Account;
import android.accounts.AccountAuthenticatorActivity;
import android.accounts.AccountManager;
import android.content.Intent;
import android.os.Bundle;
import android.text.InputType;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.view.Window;
import android.widget.EditText;

public class LoginActivity extends AccountAuthenticatorActivity {
    private static final String TAG = AccountAuthenticatorActivity.class.getSimpleName();
    private String mReAuthName = null;
    private EditText mNameEdit = null;
    private EditText mPassEdit = null;

    @Override
    public void onCreate(Bundle icicle) {
        super.onCreate(icicle);
                
        // アラートアイコン表示
        requestWindowFeature(Window.FEATURE_LEFT_ICON);
        setContentView(R.layout.login_activity);
        getWindow().setFeatureDrawableResource(Window.FEATURE_LEFT_ICON,
                                               android.R.drawable.ic_dialog_alert);

        // widgetを見つけておく
        mNameEdit = (EditText) findViewById(R.id.username_edit);
        mPassEdit = (EditText) findViewById(R.id.password_edit);
                
        // ★ポイント3★ ログイン画面Activityは公開Activityとして他のアプリからの攻撃アクセスを想定する
        // 外部入力はIntent#extrasのString型のRE_AUTH_NAMEだけしか扱わない
        // この外部入力StringはTextEdit#setText()、WebService#login()、new Account()に
        // 引数として渡されるが、どんな文字列が与えられても問題が起きないことを確認している
        mReAuthName = getIntent().getStringExtra(JssecAuthenticator.RE_AUTH_NAME);
        if (mReAuthName != null) {
            // ユーザー名指定でLoginActivityが呼び出されたので、ユーザー名を編集不可とする
            mNameEdit.setText(mReAuthName);
            mNameEdit.setInputType(InputType.TYPE_NULL);
            mNameEdit.setFocusable(false);
            mNameEdit.setEnabled(false);
        }
    }

    // ログインボタン押下時に実行される
    public void handleLogin(View view) {
        String name = mNameEdit.getText().toString();
        String pass = mPassEdit.getText().toString();

        if (TextUtils.isEmpty(name) || TextUtils.isEmpty(pass)) {
            // 入力値が不正である場合の処理
            setResult(RESULT_CANCELED);
            finish();
        }

        // 入力されたアカウント情報によりオンラインサービスにログインする
        WebService web = new WebService();
        String authToken = web.login(name, pass);
        if (TextUtils.isEmpty(authToken)) {
            // 認証が失敗した場合の処理
            setResult(RESULT_CANCELED);
            finish();
        }
                
        // 以下、ログイン成功時の処理

        // ★ポイント5★ アカウント情報や認証トークンなどのセンシティブな情報はログ出力しない
        Log.i(TAG, "WebService login succeeded");


        if (mReAuthName == null) {
            // ログイン成功したアカウントをAccountManagerに登録する
            // ★ポイント6★ Account Managerにパスワードを保存しない
            AccountManager am = AccountManager.get(this);
            Account account = new Account(name, JssecAuthenticator.JSSEC_ACCOUNT_TYPE);
            am.addAccountExplicitly(account, null, null);
            am.setAuthToken(account, JssecAuthenticator.JSSEC_AUTHTOKEN_TYPE, authToken);
            Intent intent = new Intent();
            intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, name);
            intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE,
                            JssecAuthenticator.JSSEC_ACCOUNT_TYPE);
            setAccountAuthenticatorResult(intent.getExtras());
            setResult(RESULT_OK, intent);
        } else {
            // 認証トークンを返却する
            Bundle bundle = new Bundle();
            bundle.putString(AccountManager.KEY_ACCOUNT_NAME, name);
            bundle.putString(AccountManager.KEY_ACCOUNT_TYPE,
                             JssecAuthenticator.JSSEC_ACCOUNT_TYPE);
            bundle.putString(AccountManager.KEY_AUTHTOKEN, authToken);
            setAccountAuthenticatorResult(bundle);
            setResult(RESULT_OK);
        }
        finish();
    }
}

実際にはWebServiceクラスはダミー実装となっており、常に認証が成功し固定文字列を認証トークンとして返すサンプル実装になっている。

WebService.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.accountmanager.webservice;

public class WebService {

    /**
     * オンラインサービスのアカウント管理機能にアクセスする想定
     * 
     * @param username アカウント名文字列
     * @param password パスワード文字列
     * @return 認証トークンを返す
     */
    public String login(String username, String password) {
        // ★ポイント7★ Authenticatorとオンラインサービスとの通信はHTTPSで行う
        // 実際には、サーバーとの通信処理を実装するが、 サンプルにつき割愛
        return getAuthToken(username, password);
    }

    private String getAuthToken(String username, String password) {
        // 実際にはサーバーから、ユニーク性と推測不可能性を保証された値を取得するが
        // サンプルにつき、通信は行わずに固定値を返す
        return "c2f981bda5f34f90c0419e171f60f45c";
    }
}

5.3.1.2. 独自アカウントを利用する

独自アカウントの追加と認証トークンの取得を行うアプリのサンプルコードを以下に示す。もう一つのサンプルアプリ「5.3.1.1. 独自アカウントを作る」が端末にインストールされているときに、独自アカウントの追加や認証トークンの取得ができる。「アクセスリクエスト」画面は両アプリの署名鍵が異なる場合にだけ表示される。

_images/image76.png

図 5.3.2 サンプルアプリ AccountManager User の動作画面

ポイント:

  1. Authenticatorが正規のものであることを確認してからアカウント処理を実施する

利用アプリのAndroidManifest.xml。必要なPermissionを利用宣言。必要なPermissionについては「5.3.3.1. Account Managerの利用とPermission」を参照。

AccountManager User/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.accountmanager.user" >

  <uses-permission android:name="android.permission.GET_ACCOUNTS" />
  <uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
  <uses-permission android:name="android.permission.USE_CREDENTIALS" />

  <application
      android:allowBackup="false"
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name"
      android:theme="@style/AppTheme" >
    <activity
        android:name=".UserActivity"
        android:label="@string/app_name"
        android:exported="true" >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
  </application>

</manifest>

利用アプリのActivity。画面上のボタンをタップするとaddAcount()またはgetAuthToken()が実行される。指定のアカウントタイプに対応したAuthenticatorが偽物であるケースがあるので、正規のAuthenticatorであることを確認してからアカウント処理を始めていることに注意。

UserActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.accountmanager.user;

import java.io.IOException;

import org.jssec.android.shared.PkgCert;
import org.jssec.android.shared.Utils;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AccountManagerCallback;
import android.accounts.AccountManagerFuture;
import android.accounts.AuthenticatorDescription;
import android.accounts.AuthenticatorException;
import android.accounts.OperationCanceledException;
import android.app.Activity;
import android.content.Context;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;

public class UserActivity extends Activity {

    // 利用するAuthenticatorの情報
    private static final String JSSEC_ACCOUNT_TYPE = "org.jssec.android.accountmanager";
    private static final String JSSEC_TOKEN_TYPE = "webservice";
    private TextView mLogView;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.user_activity);
                
        mLogView = (TextView)findViewById(R.id.logview);
    }

    public void addAccount(View view) {
        logLine();
        logLine("新しいアカウントを追加します");

        // ★ポイント1★ Authenticatorが正規のものであることを確認してからアカウント処理を実施する
        if (!checkAuthenticator()) return;

        AccountManager am = AccountManager.get(this);
        am.addAccount(JSSEC_ACCOUNT_TYPE, JSSEC_TOKEN_TYPE, null, null, this,
            new AccountManagerCallback<Bundle>() {
            @Override
            public void run(AccountManagerFuture<Bundle> future) {
                try {
                    Bundle result = future.getResult();
                    String type = result.getString(AccountManager.KEY_ACCOUNT_TYPE);
                    String name = result.getString(AccountManager.KEY_ACCOUNT_NAME);
                    if (type != null && name != null) {
                        logLine("以下のアカウントを追加しました:");
                        logLine("  アカウント種別: %s", type);
                        logLine("  アカウント名: %s", name);
                    } else {
                        String code = result.getString(AccountManager.KEY_ERROR_CODE);
                        String msg = result.getString(AccountManager.KEY_ERROR_MESSAGE);
                        logLine("アカウントが追加できませんでした");
                        logLine("  エラーコード %s: %s", code, msg);
                    }
                } catch (OperationCanceledException e) {
                } catch (AuthenticatorException e) {
                } catch (IOException e) {
                }
            }
        },
    null);
    }

    public void getAuthToken(View view) {
        logLine();
        logLine("トークンを取得します");

        // ★ポイント1★ Authenticatorが正規のものであることを確認してからアカウント処理を実施する
        if (!checkAuthenticator()) return;

        AccountManager am = AccountManager.get(this);
        Account[] accounts = am.getAccountsByType(JSSEC_ACCOUNT_TYPE);
        if (accounts.length > 0) {
            Account account = accounts[0];
            am.getAuthToken(account, JSSEC_TOKEN_TYPE, null, this,
                new AccountManagerCallback<Bundle>() {
                @Override
                public void run(AccountManagerFuture<Bundle> future) {
                    try {
                        Bundle result = future.getResult();
                        String name = result.getString(AccountManager.KEY_ACCOUNT_NAME);
                        String authtoken = result.getString(AccountManager.KEY_AUTHTOKEN);
                        logLine("  %sさんのトークン:", name);
                        if (authtoken != null) {
                            logLine("    %s", authtoken);
                        } else {
                            logLine("    取得できませんでした");
                        }
                    } catch (OperationCanceledException e) {
                        logLine("  例外: %s",e.getClass().getName());
                    } catch (AuthenticatorException e) {
                        logLine("  例外: %s",e.getClass().getName());
                    } catch (IOException e) {
                        logLine("  例外: %s",e.getClass().getName());
                    }
                }
            },
            null);
        } else {
            logLine("アカウントが登録されていません");
        }
    }

    // ★ポイント1★ Authenticatorが正規のものであることを確認してからアカウント処理を実施する
    private boolean checkAuthenticator() {
        AccountManager am = AccountManager.get(this);
        String pkgname = null;
        for (AuthenticatorDescription ad : am.getAuthenticatorTypes()) {
            if (JSSEC_ACCOUNT_TYPE.equals(ad.type)) {
                pkgname = ad.packageName;
                break;
            }
        }
                
        if (pkgname == null) {
            logLine("Authenticatorが見つかりません");
            return false;
        }
                
        logLine("  アカウントタイプ: %s", JSSEC_ACCOUNT_TYPE);
        logLine("  Authenticatorのパッケージ名:");
        logLine("    %s", pkgname);

        if (!PkgCert.test(this, pkgname, getTrustedCertificateHash(this))) {
            logLine("  正規のAuthenticatorではありません(証明書不一致)");
            return false;
        }
                
        logLine("  正規のAuthenticatorです");
        return true;
    }

    // 正規のAuthenticatorアプリの証明書ハッシュ値
    // サンプルアプリ JSSEC CertHash Checker で証明書ハッシュ値は確認できる
    private String getTrustedCertificateHash(Context context) {
        if (Utils.isDebuggable(context)) {
            // debug.keystoreの"androiddebugkey"の証明書ハッシュ値
            return "0EFB7236 328348A9 89718BAD DF57F544 D5CCB4AE B9DB34BC 1E29DD26 F77C8255";
        } else {
            // keystoreの"my company key"の証明書ハッシュ値
            return "D397D343 A5CBC10F 4EDDEB7C A10062DE 5690984F 1FB9E88B D7B3A7C2 42E142CA";
        }
    }
        
    private void log(String str) {
        mLogView.append(str);
    }
        
    private void logLine(String line) {
        log(line + "\n");
    }
        
    private void logLine(String fmt, Object... args) {
        logLine(String.format(fmt, args));
    }

    private void logLine() {
        log("\n");
    }
}
PkgCert.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.shared;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.Signature;

public class PkgCert {

    public static boolean test(Context ctx, String pkgname, String correctHash) {
        if (correctHash == null) return false;
        correctHash = correctHash.replaceAll(" ", "");
        return correctHash.equals(hash(ctx, pkgname));
    }

    public static String hash(Context ctx, String pkgname) {
        if (pkgname == null) return null;
        try {
            PackageManager pm = ctx.getPackageManager();
            PackageInfo pkginfo =
                pm.getPackageInfo(pkgname, PackageManager.GET_SIGNATURES);
            if (pkginfo.signatures.length != 1) return null;  // 複数署名は扱わない
            Signature sig = pkginfo.signatures[0];
            byte[] cert = sig.toByteArray();
            byte[] sha256 = computeSha256(cert);
            return byte2hex(sha256);
        } catch (NameNotFoundException e) {
            return null;
        }
    }

    private static byte[] computeSha256(byte[] data) {
        try {
            return MessageDigest.getInstance("SHA-256").digest(data);
        } catch (NoSuchAlgorithmException e) {
            return null;
        }
    }

    private static String byte2hex(byte[] data) {
        if (data == null) return null;
        final StringBuilder hexadecimal = new StringBuilder();
        for (final byte b : data) {
            hexadecimal.append(String.format("%02X", b));
        }
        return hexadecimal.toString();
    }
}

5.3.2. ルールブック

Authenticatorアプリを実装する際には以下のルールを守ること。

  1. Authenticatorを提供するServiceは非公開Serviceとする (必須)
  2. ログイン画面ActivityはAuthenticatorアプリで実装する (必須)
  3. ログイン画面Activityは公開Activityとして他のアプリからの攻撃アクセスを想定する (必須)
  4. KEY_INTENTには、ログイン画面Activityのクラス名を指定した明示的Intentを与える (必須)
  5. アカウント情報や認証トークンなどのセンシティブな情報はログ出力しない (必須)
  6. Account Managerにパスワードを保存しない (推奨)
  7. Authenticatorとオンラインサービスとの通信はHTTPSで行う (必須)

利用アプリを実装する際には以下のルールを守ること。

  1. Authenticatorが正規のものであることを確認してからアカウント処理を実施する (必須)

5.3.2.1. Authenticatorを提供するServiceは非公開Serviceとする (必須)

Authenticatorを提供するServiceはAccount Managerから利用されることを前提としており、他のアプリがアクセスできてはならない。非公開Serviceとすることにより、他のアプリからのアクセスを排除することができる。またAccount Managerはsystem権限で動作しているので非公開Serviceであってもアクセスできる。

5.3.2.2. ログイン画面ActivityはAuthenticatorアプリで実装する (必須)

新規アカウント追加および認証トークン再取得の場合に表示されるログイン画面はAuthenticatorアプリで実装すべきである。利用アプリ側で独自にログイン画面を用意してはならない。この記事の冒頭で「パスワードという極めてセンシティブな情報をアプリが扱わなくて済むことがAccount Managerの利点である。」と述べた。もし利用アプリ側でログイン画面を用意してしまうと、利用アプリがパスワードを扱ってしまうことになり、Account Managerの思想から逸脱した設計となってしまう。

Authenticatorアプリがログイン画面を用意することにより、ログイン画面を操作できるのは端末のユーザーだけに限定される。これは悪意あるアプリが直接ログインを試みたり、アカウントを作成したりといったアカウント攻撃をする手段がないということである。

5.3.2.3. ログイン画面Activityは公開Activityとして他のアプリからの攻撃アクセスを想定する (必須)

ログイン画面Activityは利用アプリの権限で起動する仕組みとなっている。利用アプリとAuthenticatorアプリの署名鍵が異なる場合にもログイン画面Activityが表示されるためには、ログイン画面Activityは公開Activityとして実装しなければならない。

ログイン画面Activityが公開Activityであるということは、悪意あるアプリからも起動される可能性があるということである。入力データは一切信用してはならない。したがって「3.2. 入力データの安全性を確認する」で述べたような対策が必要となる。

5.3.2.4. KEY_INTENTには、ログイン画面Activityのクラス名を指定した明示的Intentを与える (必須)

Authenticatorがログイン画面Activityを開きたいときには、Account Managerに返すBundleの中にログイン画面Activityを起動するIntentをKEY_INTENTで与えることになっている。ここで与えるIntentはログイン画面Activityをクラス名で指定する明示的Intentでなければならない。暗黙的Intentを与えた場合は、フレームワークがAuthenticatorアプリがログイン画面のために用意したActivity以外のActivityの起動を試みる可能性がある。これによって、Andorid 4.4(API Level 19)以降のバージョンではアプリがクラッシュしたり、Android 4.4(API Level 19)より前のバージョンでは他のアプリの用意した意図しないActivityが起動したりする場合がある。

Android 4.4(API Level 19)以降のバージョンでは、フレームワークがKEY_INTENTで与えるIntentで起動されるアプリの署名とAuthenticatorアプリの署名が一致しない場合にはSecurityExceptionを発生させるため、偽のログイン画面を起動される恐れはないが、正規のログイン画面を起動できずユーザーの正常なアプリ利用を妨げられるおそれがある。Android 4.4(API Level 19)より前のバージョンでは、悪意のあるアプリの用意した偽のログイン画面を起動され、ユーザーが悪意のあるアプリにパスワード等認証情報を入力してしまう危険がある。よって、いずれにバージョンであっても、KEY_INTENTで与えるIntentは明示的Intentでなければならない。

5.3.2.5. アカウント情報や認証トークンなどのセンシティブな情報はログ出力しない (必須)

オンラインサービスに接続するアプリは、その開発時だけでなく運用時においても、オンラインサービスにうまく接続できないトラブルに悩まされることがある。接続できない原因は多岐に渡り、ネットワーク環境の整備不足、通信プロトコルの実装ミス、Permission不足、認証エラーなど様々である。こうした原因の切り分けを目的として、プログラム内部で得られた情報をログ出力する実装もよくみられる。

パスワードや認証トークンなどのセンシティブな情報は決してログ出力してはならない。ログ情報は他のアプリからも読み取ることができるため情報漏洩の原因となりかねないからだ。アカウント名も漏洩も被害につながる場合にはログ出力してはならない。

5.3.2.6. Account Managerにパスワードを保存しない (推奨)

Account Managerに登録するアカウントには、パスワードと認証トークンの2つの認証情報を保存することができる。これらの情報は次のディレクトリのaccounts.dbの中に平文で(つまり暗号化されず)保存される。

  • Android 4.1以前
    /data/system/accounts.db
  • Android 4.2以降Android 6.0以前
    /data/system/users/0/accounts.db
  • Android 7.0以降
    /data/system_ce/0/accounts_ce.db

※Android 4.2以降はマルチユーザー機能がサポートされているため、ユーザーに合わせたディレクトリへ保存されるように変更されている。また、Android 7.0以降ではDirect Boot対応のため、ロック時にデータを扱う際のデータベース /data/system_de/0/accounts_de_db とアンロック時にデータを扱う /data/system_ce/0/accounts_ce.db にデータベースファイルが分割された。認証情報は平文の状態で後者のデータベースファイルに保存される。

このaccounts.dbの内容を読み取るためにはroot権限またはsystem権限が必要であり、市販のAndroid端末では読み取ることができない。もし、攻撃者にroot権限やsystem権限が奪われてしまう脆弱性がAndroid OSにある場合には、accounts.dbの中に保存された認証情報が危険にさらされることになる。

この記事で紹介しているAuthenticatorアプリは、Account Managerに認証トークンは保存するが、ユーザーのパスワードは保存しない設計としている。一定の期間以内にオンラインサービスに継続的に接続していれば、認証トークンの有効期間が延長されるのが一般的であるため、パスワードを保存しない設計で十分であることが多い。

認証トークンは一般にパスワードよりも有効期限が短く、いつでも無効化できる特徴がある、いわば使い捨ての認証情報である。万一、認証トークンが漏洩したとしても、認証トークンを無効化することができるため、認証トークンはパスワードに比べ安全性が高いとされている。認証トークンが無効化された場合には、ユーザーはもう一度パスワードを入力して新しい認証トークンを取得すればよい。

パスワードが漏洩した場合、パスワードを無効化してしまうと、そのユーザーはオンラインサービスを利用できなくなってしまう。このような場合、コールセンター対応等が必要となってしまうため大きなコストが発生する。ゆえにAccount Managerにパスワードを保存する設計はできるだけ避けるべきである。どうしてもパスワードを保存する設計をしなければならない場合は、パスワードを暗号化して、暗号化の鍵を難読化するなど、高度なリバースエンジニアリング対策を実施することになる。

5.3.2.7. Authenticatorとオンラインサービスとの通信はHTTPSで行う (必須)

パスワードや認証トークンはいわゆる認証情報といい、これを第三者に奪われてしまうと、第三者がユーザーになりすましできることになる。Authenticatorはオンラインサービスとこうした認証情報を送受信することになるので、HTTPS等の安全性の確立した暗号化通信方式で通信しなければならない。

5.3.2.8. Authenticatorが正規のものであることを確認してからアカウント処理を実施する (必須)

端末に同一のアカウントタイプを定義したAuthenticatorが複数存在する場合、先にインストールされたAuthenticatorが有効になる。自分のAuthenticatorが後にインストールされた場合には利用されないということである。

もし先にインストールされたAuthenticatorがマルウェアによる偽装であった場合には、ユーザーが入力したアカウント情報がマルウェアに奪われてしまう恐れがある。利用アプリはアカウント操作を行うアカウントタイプについて、正規のAuthenticatorがそのアカウントタイプに割り当てられていることを確認してから、アカウント操作を実施しなければならない。

あるアカウントタイプに割り当てられているAuthenticatorが正規のものであるかは、そのAuthenticatorを含むパッケージの証明書ハッシュ値を、事前に確認している正規の証明書ハッシュ値と一致するかどうかで確認できる。もし証明書ハッシュ値が一致しないことが判明した場合、そのアカウントタイプに割り当てられている意図しないAuthenticatorを含むパッケージをアンインストールするようユーザーを促すといった対処を施すことが望ましい。

5.3.3. アドバンスト

5.3.3.1. Account Managerの利用とPermission

AccountManagerクラスの各メソッドを利用するためには、アプリのAndroidManifest.xmlにそれぞれ適正なPermissionの利用宣言をする必要がある。Android 5.1(API Level 22)以前のバージョンではAUTHENTICATE_ACCOUNTSやGET_ACCOUNTS, MANAGE_ACCOUNTSといった権限が必要であり、メソッドとの対応を 表 5.3.1 に示す。

表 5.3.1 Account Manager の機能と Permission
  Account Managerが提供する機能
Permission メソッド 説明
AUTHENTICATE_ACCOUNTS (Authenticatorと同じ鍵で署名 されたPackageのみ利用可能) getPassword() パスワードの取得
getUserData() 利用者情報の取得
addAccountExplicitly() アカウントのDBへの追加
peekAuthToken() キャッシュされたトークンの取得
setAuthToken() 認証トークンの登録
setPassword() パスワードの変更
setUserData() 利用者情報の設定
renameAccount() アカウント名の変更
GET_ACCOUNTS getAccounts() すべてのアカウントの一覧取得
getAccountsByType() アカウントタイプが同じアカウントの一覧取得
getAccountsByTypeAndFeatures() 指定した機能を持ったアカウントの一覧取得
addOnAccountsUpdatedListener() イベントリスナーの登録
hasFeatures() 指定した機能の有無
MANAGE_ACCOUNTS getAuthTokenByFeatures() 指定した機能を持つアカウントの認証トークンの取得
addAccount() ユーザーへのアカウント追加要請
removeAccount() アカウントの削除
clearPassword() パスワードの初期化
updateCredentials() ユーザーへのパスワード変更要請
editProperties() Authenticatorの設定変更
confirmCredentials() ユーザーへのパスワード再入力要請
USE_CREDENTIALS getAuthToken() 認証トークンの取得
blockingGetAuthToken() 認証トークンの取得
MANAGE_ACCOUNTS または USE_CREDENTIALS invalidateAuthToken() キャッシュされたトークンの削除

ここで、AUTHENTICATE_ACCOUNTS Permissionが必要なメソッド群を使う場合にはPermissionに加えてパッケージの署名鍵に関する制限が設けられている。具体的には、Authenticatorを提供するパッケージの署名に使う鍵とメソッドを使うアプリのパッケージの署名に使う鍵が同じでなければならない。そのため、Authenticator以外にAUTHENTICATE_ACCOUNTS Permissionが必要なメソッド群を使うアプリを配布する際には、Authenticatorと同じ鍵で署名を施すことになる。

Android 6.0(API Level 23)以降のバージョンではGET_ACCOUNTS以外のPermissionは使用されておらず、宣言してもしなくてもできることに差はない。Android 5.1(API Level 22)以前のバージョンにおいてAUTHENTICATE_ACCOUNTSを要求していたメソッドについては、Permissionを要求しないものの、同様に署名が一致する場合のみしか呼び出せない (署名が一致しない場合にはSecurityExceptionが発生する) ことに注意する。

さらに、Android 8.0(API Level 26)でGET_ACCOUNTSを必要としていたAPIのアクセス制御も変更された。Android 8.0(API Level 26)以降のバージョンで、アカウント情報の利用側のアプリのtargetSdkVersionが26以上の場合には、GET_ACCOUNTSが付与されていたとしても原則としてAuthenticatorアプリと署名が一致する場合にしかアカウントの情報は取得できない。ただし、AuthenticatorアプリはsetAccountVisibilityメソッドを呼び出してパッケージ名を指定することで、署名の一致しないアプリに対してもアカウント情報を提供することができる。

Android Studioでの開発の際には設定した署名鍵が固定で使われるため、鍵のことを意識せずにPermissionだけで実装や動作確認が出来てしまう。特にアプリによって署名鍵を使い分けている開発者は、この制限を考慮してアプリに使う鍵を選定する必要があるので注意をすること。また、Account Managerから取得するデータにはセンシティブな情報が含まれるため、漏洩や不正利用などのリスクを減らすように扱いには十分注意すること。

5.3.3.2. Android 4.0.xでは利用アプリとAuthenticatorアプリの署名鍵が異なると例外が発生する

Authenticatorを含むAuthenticatorアプリと異なる開発者鍵で署名された利用アプリから認証トークンの取得機能が要求された場合、Account Managerは認証トークン使用許諾画面(GrantCredentialsPermissionActivity)を表示してユーザーに認証トークンの使用可否を確認する。しかし、Android 4.0.xのAndroid Frameworkには不具合があり、Account Managerによってこの画面が開かれた途端、例外が発生し、アプリが強制終了してしまう(図 5.3.3)。不具合の詳細は https://code.google.com/p/android/issues/detail?id=23421 に記載されている。Android 4.1.x以降ではこの不具合はない。

_images/image77.png

図 5.3.3 Android 標準の認証トークン使用許諾画面を表示した場合

5.3.3.3. Android 8.0(API Level 26)以降で署名の一致しないAuthenticatorのアカウントを読めるケース

Android 8.0(API Level 26)から、アカウント情報の取得などAndroid 7.1(API Level 25)以前ではGET_ACCOUNTS Permissionが必要であったメソッドの呼び出しに対してGET_ACCOUNTS Permissionが不要になり、代わりに署名が一致する場合やAuthenticatorアプリ側でsetAccountVisibilityメソッドによってアカウント情報の提供先アプリとして指定された場合にのみアカウントの情報が取得できるようになった。ただし、フレームワークの課すこのルールにはいくつかの例外があり、注意が必要である。その例外について以下に述べる。

まず、アカウント情報利用側アプリのtargetSdkVersionが25 (Android 7.1) 以下の場合には、上記のルールが適用されず、GET_ACCOUNTS Permissionを持っているアプリは署名に関係なく端末内のアカウント情報を取得できる。ただし、この挙動はAuthenticator側の実装によって変更することができることを後に述べる。

次に、WRITE_CONTACTS Permissionを利用宣言しているAuthenticatorのアカウント情報は、READ_CONTACTS Permissionを持っている他アプリから、署名に関係なく読めてしまう。これは、バグではなくフレームワークの仕様である [9]。ただし、この挙動もまた、Authenticator側の実装によって変更することができる。

[9]WRITE_CONTACTS Permissionを利用宣言しているAuthenticatorはアカウント情報をContactsProviderに書き込むとの想定で、READ_CONTACTS Permissionを持つアプリにアカウント情報取得を許可していると考えられる。

以上のように署名が一致しておらず、かつsetAccountVisibilityメソッドの呼び出しによってアカウント情報の提供先に指定していないアプリにもアカウント情報を読まれてしまう例外的なケースはあるのだが、これらの挙動はAuthenticator側で予め次のスニペットのようにsetAccountVisibilityメソッドを呼び出しておくことで変更できる。

第三者アプリにアカウント情報を提供しない

accountManager.setAccountVisibility(account, // visibilityを変更するアカウント
        AccountManager.PACKAGE_NAME_KEY_LEGACY_VISIBLE,
        AccountManager.VISIBILITY_USER_MANAGED_NOT_VISIBLE);

この通りにsetAccountVisibilityメソッドを呼び出したAuthenticatorのアカウント情報については、フレームワークはデフォルトの挙動ではなく、targetSdkVersion <= 25のケースやREAD_CONTACTSを持っている場合であってもアカウント情報を提供しないように挙動を変更する。

5.4. HTTPSで通信する

スマートフォンアプリはインターネット上のWebサーバーと通信するものが多い。その通信方式として当ガイドではHTTPとHTTPSの2方式に着目する。この2方式のうち、セキュリティの観点ではHTTPSによる通信が望ましい。近年GoogleやFacebookなど大手のWebサービスを含め、多くのサイトがHTTPSによる接続を基本とするように変わってきた。ただし、HTTPSによる接続の中でもSSL3.0や初期のTLSを用いた接続に関しては、POODLEやBEAST等の脆弱性の存在などによるセキュリティ上の問題が知られており、極力使用しないことを推奨する (「5.4.3.8. (コラム) セキュア接続のTLS1.2/TLS1.3への移行について」参照) 。

2012年以降AndroidアプリのHTTPS通信の実装方法における欠陥が多く指摘されている。これは信頼できる第三者認証局から発行されたサーバー証明書ではなく、私的に発行されたサーバー証明書(以降、プライベート証明書と呼ぶ)により運用されているテスト用Webサーバーに接続するために実装された欠陥であると推察される。

この記事では、HTTPおよびHTTPS通信の方法について説明する。HTTPS通信の方法には、プライベート証明書で運用されているWebサーバーに安全に接続する方法も含む。

5.4.1. サンプルコード

開発しているアプリの通信処理の特性を踏まえ、図 5.4.1 に従いサンプルコードを選択すること。

_images/image78.png

図 5.4.1 HTTP/HTTPS のサンプルコードを選択するフローチャート

センシティブな情報を送受信する場合はSSL/TLSで通信経路が暗号化されるHTTPS通信を用いる。HTTPS通信を用いたほうが良いセンシティブな情報としては以下のようなものがある。

  • WebサービスへのログインID、パスワード
  • 認証状態を維持するための情報(セッションID、トークン、Cookieの情報など)
  • その他Webサービスの特性に応じた重要情報・秘密情報(個人情報やクレジットカード情報など)

ここで対象となっているスマートフォンアプリは、サーバーと通信を行うことで連携し、構築されるシステムの一部を担っている。従って、通信のどの部分をHTTPもしくはHTTPSとするのかについては、システム全体を考慮して適切なセキュア設計・セキュアコーディングを施すこと。HTTPとHTTPSの通信方式の違いは 表 5.4.1 を参考にすること。またサンプルコードの違いについては 表 5.4.2 を参考にすること。

表 5.4.1 HTTP 通信方式、HTTPS 通信方式の比較
    HTTP HTTPS
特徴 URL http://で始まる https://で始まる
通信内容の暗号化 なし あり
通信内容の改ざん検知 不可
接続先サーバーの認証 不可
被害 リスク 攻撃者による通信内容の読み取り
攻撃者による通信内容の書き換え
アプリの偽サーバーへの接続
表 5.4.2 HTTP/HTTPS 通信のサンプルコードの説明
サンプルコード 通信 センシティブな情報の送受信 サーバー証明書
HTTP通信する HTTP × -
HTTPS通信する HTTPS o Cybertrust や VeriSign 等の第三者認証局により 発行されたサーバー証明書
プライベート証明書で HTTPS通信する HTTPS o プライベート証明書 ※イントラサーバーやテストサーバーで良くみられる運用形態

なおAndroidがサポートし現在広く使われているHTTP/HTTPS通信用APIは、Java SDK由来のjava.net.HttpURLConnection/javax.net.ssl.HttpsURLConnectionである。Apache HTTPComponent由来のApache HttpClientライブラリについては、Android 6.0(API Level 23)でサポートが打ち切られている。

5.4.1.1. HTTP通信する

HTTP通信で送受信する情報はすべて攻撃者に盗聴・改ざんされる可能性があることを前提としなければならない。また接続先サーバーも攻撃者が用意した偽物のサーバーに接続することがあることも前提としなければならない。このような前提においても被害が生じない、または許容範囲に収まる用途のアプリにおいてのみ、HTTP通信を利用できる。こうした前提を受け入れられないアプリについては「5.4.1.2. HTTPS通信する」や「5.4.1.3. プライベート証明書でHTTPS通信する」を参照すること。

以下のサンプルコードは、 Webサイト上の画像をHTTP通信によって取得し表示するアプリである。UIスレッドでの通信を避けるために、AsyncTaskを利用して通信処理用のワーカースレッドを作成している。Webサーバーとの通信で送受信する情報は画像のURL、画像データだが、どれもセンシティブな情報はないとみなしている。そのため、受信データである画像データは攻撃者が用意した攻撃用のデータである可能性がある [10]。 簡単のため、サンプルコードでは受信データが攻撃データであるか否かの判定・対策を施していない。同様の理由により、HTTPコネクションおよび画像データを表示する時に発生する可能性のある例外に対する例外処理を省略している。アプリの仕様に応じて適切に処理を実装する必要があることに注意すること。

また、サンプルコードはHTTP通信であるため、AndroidManifest.xmlにおいてandroid:usesCleartextTraffic属性値として、Android 8.1(API Level 27)までのでデフォルトである"true"を設定している。なお、Android 9(API Level 28)からは"false"がデフォルト(すなわちHTTPS通信がデフォルト)となったため、明示的に"true"を設定しないとHTTP通信ではエラーとなる。 このようにandroid:usesCleartextTraffic="true"と設定した場合、すべてのHTTP通信許可を意図することになる [11] が、そうではなくHTTP通信を許可するドメインを限定したい場合は、「5.4.3.7. Network Security Configuration」の「非暗号化(HTTP)通信の抑制」を参照し、設定すること。 なお、Android 7.0 (API level 24)以降では、android:usesCleartextTraffic属性よりもNetwork Security Configuration設定が優先される [12]

[10]実際、PNG画像を読み込むと任意のコードが実行されてしまう脆弱性が2019年2月に公開された。 (https://source.android.com/security/bulletin/2019-02-01.html)
[11]"This flag is honored on a best effort basis because it's impossible to prevent all cleartext traffic from Android applications given the level of access provided to them." (https://developer.android.com/reference/android/security/NetworkSecurityPolicy.html#isCleartextTrafficPermitted())
[12]https://developer.android.com/guide/topics/manifest/application-element#usesCleartextTraffic

ポイント:

  1. 送信データにセンシティブな情報を含めない
  2. 受信データが攻撃者からの送信データである場合を想定する
AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.https.imagesearch"
          android:versionCode="1"
          android:versionName="1.0">

  <uses-permission android:name="android.permission.INTERNET"/>
  
  <application
      android:icon="@drawable/ic_launcher"
      android:allowBackup="false"
      android:label="@string/app_name"
      android:usesCleartextTraffic="true">
    <activity
        android:name=".ImageSearchActivity"
        android:label="@string/app_name"
        android:theme="@android:style/Theme.Light"
        android:exported="true" >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
  </application>
</manifest>
HttpImageSearch.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.https.imagesearch;

import android.os.AsyncTask;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.ProtocolException;
import java.net.MalformedURLException;
import java.net.HttpURLConnection;
import java.net.URL;

public abstract class HttpImageSearch extends AsyncTask<String, Void, Object> {

    @Override
    protected Object doInBackground(String... params) {
        HttpURLConnection con;
        byte[] responseArray = null;
        try {
            // --------------------------------------------------------
            // 通信:画像を取得する
            // --------------------------------------------------------
            // ★ポイント1★ 送信データにセンシティブな情報を含めない
            // 画像URL(image_url をチェックして)送信する…サンプルにつき省略
            String image_url = "http://www.jssec.org/common/images/main_visual_local.jpg";
            con = connectUrl(image_url);
            checkResponse(con);

            // ★ポイント2★ 受信データが攻撃者からの送信データである場合を想定する
            // サンプルにつき検索結果が攻撃者からのデータである場合の処理は省略
            responseArray = getByteArray(con);
            if (responseArray == null) {
                return null;
            }
        } catch (IOException e) {
            // 例外処理は省略
        }
        return responseArray;
    }

    private HttpURLConnection connectUrl(String strUrl) {
        HttpURLConnection con = null;
        try {
            URL url = new URL(strUrl);
            con = (HttpURLConnection) url.openConnection();
            con.setRequestMethod("GET");
            con.connect();
        } catch (ProtocolException e) {
            // 例外処理は省略
        } catch (MalformedURLException e) {
            // 例外処理は省略
        } catch (IOException e) {
            // 例外処理は省略
        }
        return con;
    }

    private byte[] getByteArray(HttpURLConnection con) {
        byte[] buff = new byte[1024];
        byte[] result = null;
        BufferedInputStream inputStream = null;
        ByteArrayOutputStream responseArray = null;
        int length;

        try {
            inputStream = new BufferedInputStream(con.getInputStream());
            responseArray = new ByteArrayOutputStream();

            while ((length = inputStream.read(buff)) != -1) {
                if (length > 0) {
                    responseArray.write(buff, 0, length);
                }
            }
            result = responseArray.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    // 例外処理は省略
                }
            }
            if (responseArray != null) {
                try {
                    responseArray.close();
                } catch (IOException e) {
                    // 例外処理は省略
                }
            }
        }
        return result;
    }

    private void checkResponse(HttpURLConnection con) throws IOException {
        int statusCode = con.getResponseCode();
        if (HttpURLConnection.HTTP_OK != statusCode) {
            throw new IOException("HttpStatus: " + statusCode);
        }
    }
}
ImageSearchActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.https.imagesearch;

import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.View;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.TextView;

public class ImageSearchActivity extends Activity {

    private EditText mQueryBox;
    private TextView mMsgBox;
    private ImageView mImgBox;
    private AsyncTask<String, Void, Object> mAsyncTask ;
    
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        mQueryBox = (EditText)findViewById(R.id.querybox);
        mMsgBox = (TextView)findViewById(R.id.msgbox);
        mImgBox = (ImageView)findViewById(R.id.imageview);
    }

    @Override
    protected void onPause() {
        // このあとActivityが破棄される可能性があるので非同期処理をキャンセルしておく
        if (mAsyncTask != null) mAsyncTask.cancel(true);
        super.onPause();
    }

    public void onHttpSearchClick(View view) {
        mMsgBox.setText("http://www.jssec.org/common/images/main_visual_local.jpg");
        mImgBox.setImageBitmap(null);
        
        // 直前の非同期処理が終わってないこともあるのでキャンセルしておく
        if (mAsyncTask != null) mAsyncTask.cancel(true);
        
        // UIスレッドで通信してはならないので、AsyncTaskによりワーカースレッドで通信する
        mAsyncTask = new HttpImageSearch() {
            @Override
            protected void onPostExecute(Object result) {
                // UIスレッドで通信結果を処理する
                if (result == null) {
                    mMsgBox.append("\n例外発生\n");
                } else if (result instanceof Exception) {
                    Exception e = (Exception)result;
                    mMsgBox.append("\n例外発生\n" + e.toString());
                } else {
                    // サンプルにつき画像表示の際の例外処理は省略
                    byte[] data = (byte[])result;
                    Bitmap bmp = BitmapFactory.decodeByteArray(data, 0, data.length);
                    mImgBox.setImageBitmap(bmp);
                }
            }
        }.execute();    // 非同期処理を開始
    }

    public void onHttpsSearchClick(View view) {
        String query = mQueryBox.getText().toString();
        mMsgBox.setText("HTTPS:" + query);
        mImgBox.setImageBitmap(null);
        
        // 直前の非同期処理が終わってないこともあるのでキャンセルしておく
        if (mAsyncTask != null) mAsyncTask.cancel(true);
        
        // UIスレッドで通信してはならないので、AsyncTaskによりワーカースレッドで通信する
        mAsyncTask = new HttpsImageSearch() {
            @Override
            protected void onPostExecute(Object result) {
                // UIスレッドで通信結果を処理する
                if (result instanceof Exception) {
                    Exception e = (Exception)result;
                    mMsgBox.append("\n例外発生\n" + e.toString());
                } else {
                    byte[] data = (byte[])result;
                    Bitmap bmp = BitmapFactory.decodeByteArray(data, 0, data.length);
                    mImgBox.setImageBitmap(bmp);
                }
            }
        }.execute(query);    // 検索文字列を渡して非同期処理を開始
    }
}

5.4.1.2. HTTPS通信する

HTTPS通信では送受信するデータが暗号化されるだけでなく、接続先サーバーが本物かどうかの検証も行われる。そのためにHTTPS通信開始時のハンドシェイク処理において、サーバーから送られてくるサーバー証明書に対して、AndroidのHTTPSライブラリ内部で次のような観点で検証が行われる。

  • 第三者認証局により署名されたサーバー証明書であること
  • サーバー証明書の期限等が有効であること
  • サーバー証明書のSubjectのCN(Common Name)またはSAN(Subject Altername Names)のDNS名が接続先サーバーのホスト名と一致していること

これらの検証に失敗するとサーバー証明書検証エラー(SSLException)が発生する。サーバー証明書に不備がある場合、もしくは攻撃者が中間者攻撃 [13] をしている場合にこのエラーが発生する。エラーが発生した場合には、アプリの仕様に応じて適切な処理を実行する必要がある点に注意すること。

[13]中間者攻撃については、 https://www.ipa.go.jp/about/press/20140919_1.html を参照。

ここでは第三者認証局から発行されたサーバー証明書で運用されているWebサーバーに接続するHTTPS通信のサンプルコードを示す。第三者認証局から発行されたサーバー証明書ではなく、私的に発行したサーバー証明書でHTTPS通信を実現したい場合には「5.4.1.3. プライベート証明書でHTTPS通信する」を参照すること。

以下のサンプルコードは、Webサーバー上で画像検索を行い、検索画像を取得して表示するアプリである。1回の検索でサーバーとHTTPS通信を2回行う。1回目の通信で画像検索を実施し、2回目の通信で画像を取得する。UIスレッドでの通信を避けるために、AsyncTaskを利用して通信処理用のワーカースレッドを作成している。Webサーバーとの通信で送受信する情報は、画像の検索文字列、画像のURL、画像データで、全てセンシティブな情報とみなしている。なお、簡単のため、SSLExceptionに対してはユーザーへの通知などの例外処理を行っていないが、アプリの仕様に応じて適切な処理を実装する必要がある。 また、サンプルコードで 使用しているjavax.net.ssl.HttpsUrlConnectionによるHTTPS通信において、Android7.1.1以前の端末ではサーバーがSSL3.0による接続を無効としていない場合、脆弱なSSL3.0通信が成立する可能性がある。 アプリ側の対策の例として、サンプルコードではjavax.net.ssl.SSLSocketFactoryクラスを継承したカスタムクラス(NoSSLv3SocketFactoryクラス)を作成してsetEnabledProtocols()に渡すプロトコルから SSL3.0を除外している [14]。 アプリ側の対策以外では、 接続先サーバーにおいてSSL3.0を無効化する設定を施す [15] ことをお勧めする。 SSL3.0だけではなく、TLS1.0などの脆弱な初期のTLSに関しても同様である。

[14]Android 8.0(API Level 26)以降ではプラットフォームレベルで禁止しているためSSL3.0での接続は起こらない。その場合、サンプルコードのSSLSocketFactory継承による対策は必要ない。
[15]例えば Apache 2.4系の場合、ssl.conf で "SSLProtocol all -SSLv3" を設定する。

RFC2818 [16] の記載によると、サーバー証明書の検証において既存の慣行であるCNの使用は非推奨であり、ドメイン名と証明書を照合するためにはSANを使用することが強く推奨されている。 そのためAndroid 9.0(API Level 28)では検証にSANのみを用いるように変更され、サーバー側はSANを含む証明書を提示する必要があり、含まれない証明書は信頼されなくなる。

[16]"HTTP Over TLS"(https://tools.ietf.org/html/rfc2818)

ポイント:

  1. URIはhttps://で始める
  2. 送信データにセンシティブな情報を含めてよい
  3. HTTPS接続したサーバーからのデータであっても、受信データの安全性を確認する
  4. SSLExceptionに対してアプリに適した例外処理を行う
HttpsImageSearch.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.https.imagesearch;

import android.os.AsyncTask;

import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ProtocolException;
import java.net.MalformedURLException;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLSocketFactory;

import java.net.URL;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.Certificate;
import java.util.ArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public abstract class HttpsImageSearch extends AsyncTask<String, Void, Object> {

    @Override
    protected Object doInBackground(String... params) {
        HttpsURLConnection con1, con2;
        ArrayList<String> imageUrlList = new ArrayList<>();
        byte[] responseArray = null;

        try {
            // --------------------------------------------------------
            // 通信1回目:画像検索する
            // --------------------------------------------------------
            StringBuilder s = new StringBuilder();
            for (String param : params){
                s.append(param);
                s.append('+');
            }
            s.deleteCharAt(s.length() - 1);
            // ★ポイント1★ URIはhttps://で始める
            // ★ポイント2★ 送信データにセンシティブな情報を含めてよい
            // 画像検索文字列 s を(チェックして)送信する…サンプルにつき省略
            String search_url = "https://www.google.com/search?tbm=isch&q=" +
                s.toString();

            // ★ポイント3★ HTTPS接続したサーバーからのデータであっても、受信データの安全性を確認する
            // サンプルにつき省略。「3.2 入力データの安全性を確認する」を参照。
            con1 = connectUrl(search_url);
            BufferedReader in = new BufferedReader(
                                    new InputStreamReader(con1.getInputStream()));
            String inputLine;
            StringBuffer sb = new StringBuffer();
            while ((inputLine = in.readLine()) != null) {
                sb.append(inputLine);
            }
            in.close();
            final String regex = "<img.+?src=\"(.+?)\".+?>";
            Pattern pattern = Pattern.compile(regex);
            Matcher matcher = pattern.matcher(sb.toString());
            while (matcher.find()) {
                if (matcher.group(1).startsWith("https://"))
                    imageUrlList.add(matcher.group(1));
            }
            if (imageUrlList == null || imageUrlList.isEmpty()) {
                return null;
            }

            // --------------------------------------------------------
            // 通信2回目:画像を取得する
            // --------------------------------------------------------

            // ★ポイント1★ URIはhttps://で始める
            // ★ポイント2★ 送信データにセンシティブな情報を含めてよい
            String image_url = imageUrlList.get(1);
            // 画像URL(image_url をチェックして…サンプルにつき省略)送信する
            con2 = connectUrl(image_url);
            checkResponse(con2);
            responseArray = getByteArray(con2);
            if (responseArray == null) {
                return null;
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return responseArray;
    }

    private HttpsURLConnection connectUrl(String strUrl) {
        HttpsURLConnection con = null;
        try {
            SSLContext sc = SSLContext.getInstance("TLS");
            sc.init(null, null, null);
            SSLSocketFactory sf = new NoSSLv3SocketFactory(sc.getSocketFactory());
            HttpsURLConnection.setDefaultSSLSocketFactory(sf);
            URL url = new URL(strUrl);
            con = (HttpsURLConnection) url.openConnection();
            con.setRequestMethod("GET");
            con.connect();
            String cipher_suite = con.getCipherSuite();
            Certificate[] certs = con.getServerCertificates();
        } catch (SSLException e) {
            // ★ ポイント4 ★ SSLExceptionに対してアプリに適した例外処理を行う
            e.printStackTrace();// サンプルにつき例外処理は省略
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();// 例外処理は省略
        } catch (KeyManagementException e) {
            e.printStackTrace();// 例外処理は省略
        } catch (ProtocolException e) {
            e.printStackTrace();// 例外処理は省略
        } catch (MalformedURLException e) {
            e.printStackTrace();// 例外処理は省略
        } catch (IOException e) {
            e.printStackTrace();// 例外処理は省略
        }
        return con;
    }

    private byte[] getByteArray(HttpsURLConnection con) {
        byte[] buff = new byte[1024];
        byte[] result = null;
        BufferedInputStream inputStream = null;
        ByteArrayOutputStream responseArray = null;
        int length;

        try {
            inputStream = new BufferedInputStream(con.getInputStream());
            responseArray = new ByteArrayOutputStream();

            while ((length = inputStream.read(buff)) != -1) {
                if (length > 0) {
                    responseArray.write(buff, 0, length);
                }
            }
            result = responseArray.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    // 例外処理は省略
                }
            }
            if (responseArray != null) {
                try {
                    responseArray.close();
                } catch (IOException e) {
                    // 例外処理は省略
                }
            }
        }
        return result;
    }

    private void checkResponse(HttpsURLConnection con) throws IOException {
        int statusCode = con.getResponseCode();
        if (HttpsURLConnection.HTTP_OK != statusCode) {
            throw new IOException("HttpStatus: " + statusCode);
        }
    }
}
NoSSLv3SocketFactory.java
/*Copyright 2015 Bhavit Singh Sengar
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.*/

package org.jssec.android.https.imagesearch;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.Socket;
import java.net.SocketAddress;
import java.net.SocketException;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.net.ssl.HandshakeCompletedListener;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;


public class NoSSLv3SocketFactory extends SSLSocketFactory{
    private final SSLSocketFactory delegate;

    public NoSSLv3SocketFactory() {
        this.delegate = HttpsURLConnection.getDefaultSSLSocketFactory();
    }

    public NoSSLv3SocketFactory(SSLSocketFactory delegate) {
        this.delegate = delegate;
    }

    @Override
    public String[] getDefaultCipherSuites() {
        return delegate.getDefaultCipherSuites();
    }

    @Override
    public String[] getSupportedCipherSuites() {
        return delegate.getSupportedCipherSuites();
    }

    private Socket makeSocketSafe(Socket socket) {
        if (socket instanceof SSLSocket) {
            socket = new NoSSLv3SSLSocket((SSLSocket) socket);
        }
        return socket;
    }

    @Override
    public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
        return makeSocketSafe(delegate.createSocket(s, host, port, autoClose));
    }

    @Override
    public Socket createSocket(String host, int port) throws IOException {
        return makeSocketSafe(delegate.createSocket(host, port));
    }

    @Override
    public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException {
        return makeSocketSafe(delegate.createSocket(host, port, localHost, localPort));
    }

    @Override
    public Socket createSocket(InetAddress host, int port) throws IOException {
        return makeSocketSafe(delegate.createSocket(host, port));
    }

    @Override
    public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
        return makeSocketSafe(delegate.createSocket(address, port, localAddress, localPort));
    }

    private class NoSSLv3SSLSocket extends DelegateSSLSocket {

        private NoSSLv3SSLSocket(SSLSocket delegate) {
            super(delegate);

        }

        @Override
        public void setEnabledProtocols(String[] protocols) {
            if (protocols != null && protocols.length == 1 && "SSLv3".equals(protocols[0])) {

                List<String> enabledProtocols = new ArrayList<String>(Arrays.asList(delegate.getEnabledProtocols()));
                if (enabledProtocols!= null) {
                    enabledProtocols.remove("SSLv3");
                    System.out.println("Removed weak protocol from enabled protocols");
                } else {
                    System.out.println("SSL stuck with protocol available for " + String.valueOf(enabledProtocols));
                }
                protocols = enabledProtocols.toArray(new String[enabledProtocols.size()]);
            }

            super.setEnabledProtocols(protocols);
        }
    }

    public class DelegateSSLSocket extends SSLSocket {

        protected final SSLSocket delegate;

        DelegateSSLSocket(SSLSocket delegate) {
            this.delegate = delegate;
        }

        @Override
        public String[] getSupportedCipherSuites() {
            return delegate.getSupportedCipherSuites();
        }

        @Override
        public String[] getEnabledCipherSuites() {
            return delegate.getEnabledCipherSuites();
        }

        @Override
        public void setEnabledCipherSuites(String[] suites) {
            delegate.setEnabledCipherSuites(suites);
        }

        @Override
        public String[] getSupportedProtocols() {
            return delegate.getSupportedProtocols();
        }

        @Override
        public String[] getEnabledProtocols() {
            return delegate.getEnabledProtocols();
        }

        @Override
        public void setEnabledProtocols(String[] protocols) {
            delegate.setEnabledProtocols(protocols);
        }

        @Override
        public SSLSession getSession() {
            return delegate.getSession();
        }

        @Override
        public void addHandshakeCompletedListener(HandshakeCompletedListener listener) {
            delegate.addHandshakeCompletedListener(listener);
        }

        @Override
        public void removeHandshakeCompletedListener(HandshakeCompletedListener listener) {
            delegate.removeHandshakeCompletedListener(listener);
        }

        @Override
        public void startHandshake() throws IOException {
            delegate.startHandshake();
        }

        @Override
        public void setUseClientMode(boolean mode) {
            delegate.setUseClientMode(mode);
        }

        @Override
        public boolean getUseClientMode() {
            return delegate.getUseClientMode();
        }

        @Override
        public void setNeedClientAuth(boolean need) {
            delegate.setNeedClientAuth(need);
        }

        @Override
        public void setWantClientAuth(boolean want) {
            delegate.setWantClientAuth(want);
        }

        @Override
        public boolean getNeedClientAuth() {
            return delegate.getNeedClientAuth();
        }

        @Override
        public boolean getWantClientAuth() {
            return delegate.getWantClientAuth();
        }

        @Override
        public void setEnableSessionCreation(boolean flag) {
            delegate.setEnableSessionCreation(flag);
        }

        @Override
        public boolean getEnableSessionCreation() {
            return delegate.getEnableSessionCreation();
        }

        @Override
        public void bind(SocketAddress localAddr) throws IOException {
            delegate.bind(localAddr);
        }

        @Override
        public synchronized void close() throws IOException {
            delegate.close();
        }

        @Override
        public void connect(SocketAddress remoteAddr) throws IOException {
            delegate.connect(remoteAddr);
        }

        @Override
        public void connect(SocketAddress remoteAddr, int timeout) throws IOException {
            delegate.connect(remoteAddr, timeout);
        }

        @Override
        public SocketChannel getChannel() {
            return delegate.getChannel();
        }

        @Override
        public InetAddress getInetAddress() {
            return delegate.getInetAddress();
        }

        @Override
        public InputStream getInputStream() throws IOException {
            return delegate.getInputStream();
        }

        @Override
        public boolean getKeepAlive() throws SocketException {
            return delegate.getKeepAlive();
        }

        @Override
        public InetAddress getLocalAddress() {
            return delegate.getLocalAddress();
        }

        @Override
        public int getLocalPort() {
            return delegate.getLocalPort();
        }

        @Override
        public SocketAddress getLocalSocketAddress() {
            return delegate.getLocalSocketAddress();
        }

        @Override
        public boolean getOOBInline() throws SocketException {
            return delegate.getOOBInline();
        }

        @Override
        public OutputStream getOutputStream() throws IOException {
            return delegate.getOutputStream();
        }

        @Override
        public int getPort() {
            return delegate.getPort();
        }

        @Override
        public synchronized int getReceiveBufferSize() throws SocketException {
            return delegate.getReceiveBufferSize();
        }

        @Override
        public SocketAddress getRemoteSocketAddress() {
            return delegate.getRemoteSocketAddress();
        }

        @Override
        public boolean getReuseAddress() throws SocketException {
            return delegate.getReuseAddress();
        }

        @Override
        public synchronized int getSendBufferSize() throws SocketException {
            return delegate.getSendBufferSize();
        }

        @Override
        public int getSoLinger() throws SocketException {
            return delegate.getSoLinger();
        }

        @Override
        public synchronized int getSoTimeout() throws SocketException {
            return delegate.getSoTimeout();
        }

        @Override
        public boolean getTcpNoDelay() throws SocketException {
            return delegate.getTcpNoDelay();
        }

        @Override
        public int getTrafficClass() throws SocketException {
            return delegate.getTrafficClass();
        }

        @Override
        public boolean isBound() {
            return delegate.isBound();
        }

        @Override
        public boolean isClosed() {
            return delegate.isClosed();
        }

        @Override
        public boolean isConnected() {
            return delegate.isConnected();
        }

        @Override
        public boolean isInputShutdown() {
            return delegate.isInputShutdown();
        }

        @Override
        public boolean isOutputShutdown() {
            return delegate.isOutputShutdown();
        }

        @Override
        public void sendUrgentData(int value) throws IOException {
            delegate.sendUrgentData(value);
        }

        @Override
        public void setKeepAlive(boolean keepAlive) throws SocketException {
            delegate.setKeepAlive(keepAlive);
        }

        @Override
        public void setOOBInline(boolean oobinline) throws SocketException {
            delegate.setOOBInline(oobinline);
        }

        @Override
        public void setPerformancePreferences(int connectionTime, int latency, int bandwidth) {
            delegate.setPerformancePreferences(connectionTime, latency, bandwidth);
        }

        @Override
        public synchronized void setReceiveBufferSize(int size) throws SocketException {
            delegate.setReceiveBufferSize(size);
        }

        @Override
        public void setReuseAddress(boolean reuse) throws SocketException {
            delegate.setReuseAddress(reuse);
        }

        @Override
        public synchronized void setSendBufferSize(int size) throws SocketException {
            delegate.setSendBufferSize(size);
        }

        @Override
        public void setSoLinger(boolean on, int timeout) throws SocketException {
            delegate.setSoLinger(on, timeout);
        }

        @Override
        public synchronized void setSoTimeout(int timeout) throws SocketException {
            delegate.setSoTimeout(timeout);
        }

        @Override
        public void setTcpNoDelay(boolean on) throws SocketException {
            delegate.setTcpNoDelay(on);
        }

        @Override
        public void setTrafficClass(int value) throws SocketException {
            delegate.setTrafficClass(value);
        }

        @Override
        public void shutdownInput() throws IOException {
            delegate.shutdownInput();
        }

        @Override
        public void shutdownOutput() throws IOException {
            delegate.shutdownOutput();
        }

        @Override
        public String toString() {
            return delegate.toString();
        }

        @Override
        public boolean equals(Object o) {
            return delegate.equals(o);
        }
    }
}

サンプルコードの他のファイル(AndroidManifest.xml, ImageSearchActivity.java)については「5.4.1.1. HTTP通信する」と共用しているので「5.4.1.1. HTTP通信する」も参照すること。

5.4.1.3. プライベート証明書でHTTPS通信する

ここでは第三者認証局から発行されたサーバー証明書ではなく、私的に発行したサーバー証明書(プライベート証明書)でHTTPS通信をするサンプルコードを示す。プライベート認証局のルート証明書とプライベート証明書の作成方法およびWebサーバーのHTTPS設定については「5.4.3.1. プライベート証明書の作成方法とサーバー設定」を参考にすること。またサンプルプログラムのassets中のcacert.crtファイルはプライベート認証局のルート証明書ファイルである。

以下のサンプルコードは、Webサーバー上の画像を取得して表示するアプリである。WebサーバーとはHTTPSを用いた通信を行う。UIスレッドでの通信を避けるために、AsyncTaskを利用して通信処理用のワーカースレッドを作成している。Webサーバーとの通信で送受信する情報は画像のURLと画像データで、このサンプルではどちらもセンシティブな情報とみなしている。また、簡単のため、SSLExceptionに対してはユーザーへの通知などの例外処理を行っていないが、アプリの仕様に応じて適切な処理を実装する必要がある。

ポイント:

  1. プライベート認証局のルート証明書でサーバー証明書を検証する
  2. URIはhttps://で始める
  3. 送信データにセンシティブな情報を含めてよい
  4. 受信データを接続先サーバーと同じ程度に信用してよい
  5. SSLExceptionに対しユーザーに通知する等の適切な例外処理をする
PrivateCertificateHttpsGet.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.https.privatecertificate;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.KeyStore;
import java.security.SecureRandom;

import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.TrustManagerFactory;

import android.content.Context;
import android.os.AsyncTask;

public abstract class PrivateCertificateHttpsGet extends AsyncTask<String, Void, Object> {

    private Context mContext;

    public PrivateCertificateHttpsGet(Context context) {
        mContext = context;
    }

    @Override
    protected Object doInBackground(String... params) {
        TrustManagerFactory trustManager;
        BufferedInputStream inputStream = null;
        ByteArrayOutputStream responseArray = null;
        byte[] buff = new byte[1024];
        int length;

        try {
            URL url = new URL(params[0]);
            // ★ポイント1★ プライベート認証局のルート証明書でサーバー証明書を検証する
            // assetsに格納しておいたプライベート証明書だけを含むKeyStoreを設定
            KeyStore ks = KeyStoreUtil.getEmptyKeyStore();
            KeyStoreUtil.loadX509Certificate(ks,
                mContext.getResources().getAssets().open("cacert.crt"));

            // ホスト名の検証を行う
            HttpsURLConnection.setDefaultHostnameVerifier(new HostnameVerifier() {
                @Override
                public boolean verify(String hostname, SSLSession session) {
                    if (!hostname.equals(session.getPeerHost())) {
                        return false;
                    }
                    return true;
                }
            });

            // ★ポイント2★ URIはhttps://で始める
            // ★ポイント3★ 送信データにセンシティブな情報を含めてよい
            trustManager =
                TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
            trustManager.init(ks);
            SSLContext sslCon = SSLContext.getInstance("TLS");
            sslCon.init(null, trustManager.getTrustManagers(), new SecureRandom());

            HttpURLConnection con = (HttpURLConnection)url.openConnection();
            HttpsURLConnection response = (HttpsURLConnection)con;
            response.setDefaultSSLSocketFactory(sslCon.getSocketFactory());

            response.setSSLSocketFactory(sslCon.getSocketFactory());
            checkResponse(response);

            // ★ポイント4★ 受信データを接続先サーバーと同じ程度に信用してよい
            inputStream = new BufferedInputStream(response.getInputStream());
            responseArray = new ByteArrayOutputStream();
            while ((length = inputStream.read(buff)) != -1) {
                if (length > 0) {
                    responseArray.write(buff, 0, length);
                }
            }
            return responseArray.toByteArray();
        } catch(SSLException e) {
            // ★ポイント5★ SSLExceptionに対しユーザーに通知する等の適切な例外処理をする
            // サンプルにつき例外処理は割愛
            return e;
        } catch(Exception e) {
            return e;
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (Exception e) {
                    // 例外処理は割愛
                }
            }
            if (responseArray != null) {
                try {
                    responseArray.close();
                } catch (Exception e) {
                    // 例外処理は割愛
                }
            }
        }
    }

    private void checkResponse(HttpURLConnection response) throws IOException {
        int statusCode = response.getResponseCode();
        if (HttpURLConnection.HTTP_OK != statusCode) {
            throw new IOException("HttpStatus: " + statusCode);
        }
    }
}
KeyStoreUtil.java
package org.jssec.android.https.privatecertificate;

import java.io.IOException;
import java.io.InputStream;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Enumeration;

public class KeyStoreUtil {
    public static KeyStore getEmptyKeyStore() throws KeyStoreException,
                                                     NoSuchAlgorithmException,
                                                     CertificateException,
                                                     IOException {
        KeyStore ks = KeyStore.getInstance("BKS");
        ks.load(null);
        return ks;
    }

    public static void loadAndroidCAStore(KeyStore ks)
        throws KeyStoreException, NoSuchAlgorithmException,
               CertificateException, IOException {
        KeyStore aks = KeyStore.getInstance("AndroidCAStore");
        aks.load(null);
        Enumeration<String> aliases = aks.aliases();
        while (aliases.hasMoreElements()) {
            String alias = aliases.nextElement();
            Certificate cert = aks.getCertificate(alias);
            ks.setCertificateEntry(alias, cert);
        }
    }
    
    public static void loadX509Certificate(KeyStore ks, InputStream is)
        throws CertificateException, KeyStoreException {
        try {
            CertificateFactory factory = CertificateFactory.getInstance("X509");
            X509Certificate x509 =
                (X509Certificate)factory.generateCertificate(is);
            String alias = x509.getSubjectDN().getName();
            ks.setCertificateEntry(alias, x509);
        } finally {
            try { is.close(); } catch (IOException e) { /* 例外処理は割愛 */ }
        }
    }
}
PrivateCertificateHttpsActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.https.privatecertificate;

import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.View;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.TextView;

public class PrivateCertificateHttpsActivity extends Activity {

    private EditText mUrlBox;
    private TextView mMsgBox;
    private ImageView mImgBox;
    private AsyncTask<String, Void, Object> mAsyncTask ;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        mUrlBox = (EditText)findViewById(R.id.urlbox);
        mMsgBox = (TextView)findViewById(R.id.msgbox);
        mImgBox = (ImageView)findViewById(R.id.imageview);
    }
    
    @Override
    protected void onPause() {
        // このあとActivityが破棄される可能性があるので非同期処理をキャンセルしておく
        if (mAsyncTask != null) mAsyncTask.cancel(true);
        super.onPause();
    }
    
    public void onClick(View view) {
        String url = mUrlBox.getText().toString();
        mMsgBox.setText(url);
        mImgBox.setImageBitmap(null);
        
        // 直前の非同期処理が終わってないこともあるのでキャンセルしておく
        if (mAsyncTask != null) mAsyncTask.cancel(true);
        
        // UIスレッドで通信してはならないので、AsyncTaskによりワーカースレッドで通信する
        mAsyncTask = new PrivateCertificateHttpsGet(this) {
                @Override
                protected void onPostExecute(Object result) {
                    // UIスレッドで通信結果を処理する
                    if (result instanceof Exception) {
                        Exception e = (Exception)result;
                        mMsgBox.append("\n例外発生\n" + e.toString());
                    } else {
                        byte[] data = (byte[])result;
                        Bitmap bmp = BitmapFactory.decodeByteArray(data, 0, data.length);
                        mImgBox.setImageBitmap(bmp);
                    }
                }
            }.execute(url);     // URLを渡して非同期処理を開始
    }
}

5.4.2. ルールブック

HTTP通信、HTTPS通信する場合には以下のルールを守ること。

  1. センシティブな情報はHTTPS通信で送受信する (必須)
  2. HTTP通信では受信データの安全性を確認する (必須)
  3. SSLExceptionに対しユーザーに通知する等の適切な例外処理をする (必須)
  4. 独自のTrustManagerを作らない (必須)
  5. 独自のHostnameVerifierは作らない (必須)

5.4.2.1. センシティブな情報はHTTPS通信で送受信する (必須)

HTTPを使った通信では、送受信する情報の盗聴・改ざん、または、接続先サーバーのなりすましが起こる可能性がある。センシティブな情報はHTTPS通信で送受信すること。

5.4.2.2. HTTP通信では受信データの安全性を確認する (必須)

HTTP通信における受信データは攻撃者が制御可能であるため、コード脆弱性を狙った攻撃データを受信する可能性がある。あらゆる値、形式のデータを受信することを想定して、受信データを処理するコードに脆弱性がないように気を付けてコーディングする必要がある。また、HTTPS通信における受信データについても、受信データを無条件に安全であると考えてはならない。HTTPS接続先のサーバーが攻撃者によって用意されたものである場合や、受信データが接続先サーバーとは別の場所で生成されたデータである場合もあるためである。「3.2. 入力データの安全性を確認する」も参照すること。

5.4.2.3. SSLExceptionに対しユーザーに通知する等の適切な例外処理をする (必須)

HTTPS通信ではサーバー証明書の検証時にSSLExceptionが発生することがある。SSLExceptionはサーバー証明書の不備が原因となって発生する。証明書の不備は攻撃者による中間者攻撃によって発生している可能性があるので、SSLExceptionに対しては適切な例外処理を実装することが必要である。例外処理の例としては、SSLExceptionによる通信失敗をユーザーに通知すること、あるいはログに記録することが考えられる。その一方で、アプリによってはユーザーに対する特別な通知は必要とされないこともありうる。このように、実装すべき処理はアプリの仕様や特性によって異なるので、それらを十分に検討した上で決定しなければならない。

加えて、SSLExceptionが発生した場合には中間者攻撃を受けている可能性があるので、HTTPなどの非暗号化通信によってセンシティブな情報の送受信を再度試みるような実装してはならない。

5.4.2.4. 独自のTrustManagerを作らない (必須)

自己署名証明書などのプライベート証明書でHTTPS通信するためには、サーバー証明書検証に使うKeyStoreを変更するだけで済む。しかしながら「5.4.3.3. 証明書検証を無効化する危険なコード」で説明しているように、インターネット上で公開されているサンプルコードには、危険なTrustManagerを実装する例を紹介しているものが多くある。これらのサンプルを参考にして実装されたアプリは、脆弱性を作りこむ可能性がある。

プライベート証明書でHTTPS通信をしたい場合には「5.4.1.3. プライベート証明書でHTTPS通信する」の安全なサンプルコードを参照すること。

本来ならば独自のTrustManagerを安全に実装することも可能であるが、暗号処理や暗号通信に十分な知識をもった技術者でなければミスを作り込む危険性があるため、このルールはあえて必須とした。

5.4.2.5. 独自のHostnameVerifierは作らない (必須)

自己署名証明書などのプライベート証明書でHTTPS通信するためには、サーバー証明書検証に使うKeyStoreを変更するだけで済む。しかしながら「5.4.3.3. 証明書検証を無効化する危険なコード」で説明しているように、インターネット上で公開されているサンプルコードには、危険なHostnameVerifierを利用する例を紹介しているものが多くある。これらのサンプルを参考にして実装したアプリは、脆弱性を作りこむ可能性がある。

プライベート証明書でHTTPS通信をしたい場合には「5.4.1.3. プライベート証明書でHTTPS通信する」の安全なサンプルコードを参照すること。

本来ならば独自のHostnameVerifierを安全に実装することも可能であるが、暗号処理や暗号通信に十分な知識をもった技術者でなければミスを作り込む危険性があるため、このルールはあえて必須とした。

5.4.3. アドバンスト

5.4.3.1. プライベート証明書の作成方法とサーバー設定

ここではUbuntuやCentOSなどのLinux環境におけるプライベート証明書の作成方法とサーバー設定について説明する。プライベート証明書は私的に発行されたサーバー証明書のことである。CybertrustやVeriSignなどの第三者認証局から発行されたサーバー証明書と区別してプライベート証明書と呼ばれる。

プライベート認証局の作成

まずプライベート証明書を発行するためのプライベート認証局を作成する。CybertrustやVeriSignなどの第三者認証局と区別してプライベート認証局と呼ばれる。1つのプライベート認証局で複数のプライベート証明書を発行できる。プライベート認証局を作成したPCは、限られた信頼できる人物しかアクセスできないように厳重に管理されなければならない。

プライベート認証局を作成するには、下記のシェルスクリプトnewca.shおよび設定ファイルopenssl.cnfを作成し実行する。シェルスクリプト中のCASTARTおよびCAENDは認証局の有効期間、CASUBJは認証局の名称であるので、作成する認証局に合わせて変更すること。シェルスクリプト実行の際には認証局アクセスのためのパスワードが合計3回聞かれるので、同じパスワードを入力すること。

newca.sh -- プライベート認証局を作成するシェルスクリプト
#!/bin/bash

umask 0077

CONFIG=openssl.cnf
CATOP=./CA
CAKEY=cakey.pem
CAREQ=careq.pem
CACERT=cacert.pem
CAX509=cacert.crt
CASTART=130101000000Z   # 2013/01/01 00:00:00 GMT
CAEND=230101000000Z     # 2023/01/01 00:00:00 GMT
CASUBJ="/CN=JSSEC Private CA/O=JSSEC/ST=Tokyo/C=JP"

mkdir -p ${CATOP}
mkdir -p ${CATOP}/certs
mkdir -p ${CATOP}/crl
mkdir -p ${CATOP}/newcerts
mkdir -p ${CATOP}/private
touch ${CATOP}/index.txt

openssl req -new -newkey rsa:2048 -sha256 -subj "${CASUBJ}" \
        -keyout ${CATOP}/private/${CAKEY} -out ${CATOP}/${CAREQ}
openssl ca -selfsign -md sha256 -create_serial -batch \
        -keyfile ${CATOP}/private/${CAKEY} \
        -startdate ${CASTART} -enddate ${CAEND} -extensions v3_ca \
        -in ${CATOP}/${CAREQ} -out ${CATOP}/${CACERT} \
        -config ${CONFIG}
openssl x509 -in ${CATOP}/${CACERT} -outform DER -out ${CATOP}/${CAX509}
openssl.cnf - 2つのシェルスクリプトが共通に参照するopensslコマンドの設定ファイル
[ ca ]
default_ca      = CA_default             # The default ca section

[ CA_default ]
dir             = ./CA                   # Where everything is kept
certs           = $dir/certs             # Where the issued certs are kept
crl_dir         = $dir/crl               # Where the issued crl are kept
database        = $dir/index.txt         # database index file.
#unique_subject = no                     # Set to 'no' to allow creation of several ctificates with same subject.
new_certs_dir   = $dir/newcerts          # default place for new certs.
certificate     = $dir/cacert.pem        # The CA certificate
serial          = $dir/serial            # The current serial number
crlnumber       = $dir/crlnumber         # the current crl number must be commented out to leave a V1 CRL
crl             = $dir/crl.pem           # The current CRL
private_key     = $dir/private/cakey.pem # The private key
RANDFILE        = $dir/private/.rand     # private random number file
x509_extensions = usr_cert               # The extentions to add to the cert
name_opt        = ca_default             # Subject Name options
cert_opt        = ca_default             # Certificate field options
policy          = policy_match

[ policy_match ]
countryName             = match
stateOrProvinceName     = match
organizationName        = supplied
organizationalUnitName  = optional
commonName              = supplied
emailAddress            = optional

[ usr_cert ]
basicConstraints        = CA:FALSE
nsComment               = "OpenSSL Generated Certificate"
subjectKeyIdentifier    = hash
authorityKeyIdentifier  = keyid,issuer
subjectAltName          = @alt_names

[ v3_ca ]
subjectKeyIdentifier    = hash
authorityKeyIdentifier  = keyid:always,issuer
basicConstraints        = CA:true

[ alt_names ]
DNS.1                   = ${ENV::HOSTNAME}
DNS.2                   = *.${ENV::HOSTNAME}

上記シェルスクリプトを実行すると、作業ディレクトリ直下にCAというディレクトリが作成される。このCAディレクトリがプライベート認証局である。CA/cacert.crtファイルがプライベート認証局のルート証明書であり、「5.4.1.3. プライベート証明書でHTTPS通信する」のassetsに使用されたり、「5.4.3.2. Android OSの証明書ストアにプライベート認証局のルート証明書をインストールする」でAndroid端末にインストールされたりする。

プライベート証明書の作成

プライベート証明書を作成するには、下記のシェルスクリプトnewsv.shを作成し実行する。シェルスクリプト中のSVSTARTおよびSVENDはプライベート証明書の有効期間、SVSUBJはWebサーバーの名称であるので、対象Webサーバーに合わせて変更すること。特にSVSUBJの/CNで指定するWebサーバーのホスト名はタイプミスがないように気を付けること。シェルスクリプトを実行すると認証局アクセスのためのパスワードが聞かれるので、プライベート認証局を作成するときに指定したパスワードを入力すること。その後、合計2回のy/nを聞かれるのでyを入力すること。

newsv.sh - プライベート証明書を発行するシェルスクリプト
#!/bin/bash

umask 0077

CONFIG=openssl.cnf
CATOP=./CA
CAKEY=cakey.pem
CACERT=cacert.pem
SVKEY=svkey.pem
SVREQ=svreq.pem
SVCERT=svcert.pem
SVX509=svcert.crt
SVSTART=130101000000Z   # 2013/01/01 00:00:00 GMT
SVEND=230101000000Z     # 2023/01/01 00:00:00 GMT
HOSTNAME=selfsigned.jssec.org
SVSUBJ="/CN="${HOSTNAME}"/O=JSSEC Secure Coding Group/ST=Tokyo/C=JP"

openssl genrsa -out ${SVKEY} 2048
openssl req -new -key ${SVKEY} -subj "${SVSUBJ}" -out ${SVREQ}
openssl ca -md sha256 \
        -keyfile ${CATOP}/private/${CAKEY} -cert ${CATOP}/${CACERT} \
        -startdate ${SVSTART} -enddate ${SVEND} \
        -in ${SVREQ} -out ${SVCERT} -config ${CONFIG}
openssl x509 -in ${SVCERT} -outform DER -out ${SVX509}

上記シェルスクリプトを実行すると、作業ディレクトリ直下にWebサーバー用のプライベートキーファイルsvkey.pemおよびプライベート証明書ファイルsvcert.pemが生成される。

WebサーバーがApacheである場合には、設定ファイル中に上で作成したprikey.pemとcert.pemを次のように指定するとよい。

SSLCertificateFile "/path/to/svcert.pem"
SSLCertificateKeyFile "/path/to/svkey.pem"

5.4.3.2. Android OSの証明書ストアにプライベート認証局のルート証明書をインストールする

5.4.1.3. プライベート証明書でHTTPS通信する」のサンプルコードは、1つのアプリにプライベート認証局のルート証明書を持たせることで、プライベート証明書で運用するWebサーバーにHTTPS接続する方法を紹介した。ここではAndroid OSにプライベート認証局のルート証明書をインストールすることで、すべてのアプリがプライベート証明書で運用するWebサーバーにHTTPS接続する方法を紹介する。インストールしてよいのは、信頼できる認証局の発行した証明書に限ることに注意すること。

ただし、ここで述べる方法はAndroid 6.0(API Level 23)以前のバージョンにのみ適用可能なものである。Android 7.0(API Level 24)以降では、プライベート認証局のルート証明書をインストールしても、システムはこれを無視する。API Level 24以降でプライベート証明書を利用したい場合は、「5.4.3.7. Network Security Configuration」の「プライベート証明書でHTTPS通信する」の項を参照されたい。

まずプライベート認証局のルート証明書ファイルcacert.crtをAndroid端末の内部ストレージにコピーする。なおサンプルコードで使用しているルート証明書ファイルはhttps://www.jssec.org/dl/android_securecoding_sample_cacert.crtからも取得できる。

次にAndroidの設定メニューのセキュリティを開き、下図のような手順を進めることでAndroid OSにルート証明書をインストールすることができる。

_images/image79.png

図 5.4.2 プライベート認証局のルート証明書のインストール手順

_images/image80.png

図 5.4.3 ルート証明書がインストールされていることの確認

Android OSにプライベート認証局のルート証明書をインストールすると、その認証局から発行されたプライベート証明書をすべてのアプリで正しく証明書検証できるようになる。下図はChromeブラウザで https://selfsigned.jssec.org/droid_knight.png を表示した場合の例である。

_images/image81.png

図 5.4.4 ルート証明書のインストール後はプライベート証明書を正しく検証できるようになる

この方法を使えば「5.4.1.2. HTTPS通信する」のサンプルコードでもプライベート証明書で運用するWebサーバーにHTTPS接続できるようになる。

5.4.3.3. 証明書検証を無効化する危険なコード

インターネット上にはサーバー証明書検証エラーを無視してHTTPS通信をするサンプルコードが多数掲載されている。これらのサンプルコードはプライベート証明書を使ってHTTPS通信を実現する方法として紹介されているため、そうしたサンプルコードをコピー&ペーストして利用しているアプリが多数存在している。残念ながらこうしたサンプルコードは中間者攻撃に脆弱なものであることが多く、この記事の冒頭で「2012年にはAndroidアプリのHTTPS通信の実装方法における欠陥が多く指摘された。」と述べたように、こうしたインターネット上の脆弱なサンプルコードを利用してしまったと思われる多くの脆弱なAndroidアプリが報告されている。

ここではこうした脆弱なHTTPS通信のサンプルコードの断片を紹介する。こうしたサンプルコードを見かけた場合には「5.4.1.3. プライベート証明書でHTTPS通信する」のサンプルコードに置き換えるなどしていただきたい。

危険:空っぽのTrustManagerを作るケース

    TrustManager tm = new X509TrustManager() {

        @Override
        public void checkClientTrusted(X509Certificate[] chain,
                                       String authType) throws CertificateException {
            // 何もしない → どんな証明書でも受付ける
        }

        @Override
        public void checkServerTrusted(X509Certificate[] chain,
                                       String authType) throws CertificateException {
            // 何もしない → どんな証明書でも受付ける
        }

        @Override
        public X509Certificate[] getAcceptedIssuers() {
            return null;
        }
    };

危険:空っぽのHostnameVerifierを作るケース

    HostnameVerifier hv = new HostnameVerifier() {
        @Override
        public boolean verify(String hostname, SSLSession session) {
            // 常にtrueを返す → どんなホスト名でも受付ける
            return true;
        }
    };

危険:ALLOW_ALL_HOSTNAME_VERIFIERを使っているケース

    SSLSocketFactory sf;
    // …
    sf.setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);

5.4.3.4. HTTPリクエストヘッダを設定する際の注意点

HTTPおよびHTTPS通信において、独自のHTTPリクエストヘッダを設定したい場合は、URLConnectionクラスのsetRequestProperty()メソッド、もしくはaddRequestProperty()メソッドを使用する。これらメソッドの引数に外部からの入力データを用いる場合は、HTTPヘッダ・インジェクションの対策が必要となる。HTTPヘッダ・インジェクションによる攻撃の最初のステップとなるのは、HTTPヘッダの区切り文字である改行コードを入力データに含めることであるため、入力データから改行コードを排除するようにしなければならない。

HTTPリクエストヘッダを設定する

public byte[] openConnection(String strUrl, String strLanguage, String strCookie) {
    // HttpURLConnection はURLConnectionの派生クラス
    HttpURLConnection connection;

    try {
        URL url = new URL(strUrl);
        connection = (HttpURLConnection) url.openConnection();
        connection.setRequestMethod("GET");

        // ★ポイント★ HTTPリクエストヘッダに入力値を使用する場合は、アプリケーション要件に従って
        // 入力データをチェックする(※)
        if (strLanguage.matches("^[a-zA-Z ,-]+$")) {
            connection.addRequestProperty("Accept-Language", strLanguage);
        } else {
            throw new IllegalArgumentException("Invalid Language : " + strLanguage);
        }
        // ★ポイント★ もしくは入力データをURLエンコードする(というアプリケーション要件にする)
        connection.setRequestProperty("Cookie", URLEncoder.encode(strCookie, "UTF-8"));

        connection.connect();

        // ~省略~

 ※「3.2. 入力データの安全性を確認する」を参照

5.4.3.5. ピンニングによる検証の注意点と実装例

アプリがHTTPS通信を行う際は、通信開始時のハンドシェイク処理において、接続先サーバーから送られてくる証明書が第三者認証局により署名されているかどうかの検証が行われる。しかし、攻撃者が第三者認証局から不正な証明書を入手したり、認証局の署名鍵を入手して不正な証明書を作成したりした場合、その攻撃者により不正なサーバーへの誘導や中間者攻撃が行われても、アプリはそれらの攻撃をハンドシェイク処理で検出することができず、結果として被害につながってしまう可能性がある。

このような不正な第三者認証局の証明書を用いた中間者攻撃に対しては「ピンニングによる検証」が有効である。これは、あらかじめ接続先サーバーの証明書や公開鍵をアプリ内に保持しておき、それらの情報をハンドシェイク処理で用いたり、ハンドシェイク処理後に再検証したりする方法である。

ピンニングによる検証は、公開鍵基盤(PKI)の基礎である第三者認証局の信頼性が損なわれた場合に備え、通信の安全性を補填する目的で用いられる。開発者は自身のアプリが扱う資産レベルに応じて、この検証を行うかどうか検討してほしい。

アプリ内に保持した証明書・公開鍵をハンドシェイク処理で使用する

アプリ内に保持しておいた接続先サーバーの証明書や公開鍵の情報をハンドシェイク処理で用いるためには、それらの情報を含めた独自のKeyStoreを作成して通信に用いる。これにより、上記のような不正な第三者認証局の証明書を用いた中間者攻撃が行われても、ハンドシェイク処理において不正を検出することができるようになる。独自のKeyStoreを設定してHTTPS通信を行う具体的な方法は「5.4.1.3. プライベート証明書でHTTPS通信する」で紹介したサンプルコードを参照すること。

アプリ内に保持した証明書・公開鍵を用いてハンドシェイク処理後に再検証する

ハンドシェイク処理が行われた後に接続先を再検証するためには、まずハンドシェイク処理で検証されシステムに信頼された証明書チェーンを取得し、その証明書チェーンを、あらかじめアプリ内に保持しておいた情報と照合する。照合の結果、保持しておいた情報と一致するものが含まれていれば通信を許可し、含まれていなければ通信処理を中断させればよい。

ただし、ハンドシェイク処理でシステムに信頼された証明書チェーンを取得する際に以下のメソッドを使用すると、期待通りの証明書チェーンが得られず、結果としてピンニングによる検証が正常に機能しなくなってしまう危険がある [17]

[17]この危険性については、 https://www.synopsys.com/blogs/software-security/ineffective-certificate-pinning-implementations/ の記事で詳しく説明されている。
  • javax.net.ssl.SSLSession.getPeerCertificates()
  • javax.net.ssl.SSLSession.getPeerCertificateChain()

これらのメソッドが返すのは、ハンドシェイク処理でシステムに信頼された証明書チェーンではなく、アプリが通信相手から受け取った証明書チェーンそのものである。そのため、中間者攻撃により不正な第三者認証局の証明書が証明書チェーンに付け加えられても、上記のメソッドはハンドシェイク処理でシステムが信用した証明書だけでなく、本来アプリが接続しようとしていたサーバーの証明書も一緒に返してしまう。この「本来アプリが接続しようとしていたサーバーの証明書」は、ピンニングによる検証のためアプリ内にあらかじめ保持しておいたものと同等の証明書なので、再検証を行っても不正を検出することができない。このような理由から、ハンドシェイク処理後の再検証を実装する際に上記のメソッドを使用することは避けるべきである。

Android 4.2(API Level 17)以上であれば、上記のメソッドの代わりにnet.http.X509TrustManagerExtensionsのcheckServerTrusted()を使用することで、ハンドシェイク処理でシステムに信頼された証明書チェーンのみを取得することができる。

X509TrustManagerExtensionsを用いたピンニング検証の例

// 正しい通信先サーバーの証明書に含まれる公開鍵のSHA-256ハッシュ値を保持(ピンニング)
private static final Set<String> PINS = new HashSet<>(Arrays.asList(
    new String[] {
        "d9b1a68fceaa460ac492fb8452ce13bd8c78c6013f989b76f186b1cbba1315c1",
        "cd13bb83c426551c67fabcff38d4496e094d50a20c7c15e886c151deb8531cdc"
    }
));

// AsyncTaskのワーカースレッドで通信する
protected Object doInBackground(String... strings) {

    // ~省略~

    // ハンドシェイク時の検証によりシステムに信頼された証明書チェーンを取得する
    X509Certificate[] chain = (X509Certificate[]) connection.getServerCertificates();
    X509TrustManagerExtensions trustManagerExt =
        new X509TrustManagerExtensions((X509TrustManager) (trustManagerFactory
                                                           .getTrustManagers()[0]));
    List<X509Certificate> trustedChain =
        trustManagerExt.checkServerTrusted(chain, "RSA", url.getHost());

    // 公開鍵ピンニングを用いて検証する
    boolean isValidChain = false;
    for (X509Certificate cert : trustedChain) {
        PublicKey key = cert.getPublicKey();
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        String keyHash = bytesToHex(md.digest(key.getEncoded()));

        // ピンニングしておいた公開鍵のハッシュ値と比較する
        if(PINS.contains(keyHash)) isValidChain = true;
    }
    if (isValidChain) {
        // 処理を継続する
    } else {
        // 処理を継続しない
    }

    // ~省略~
}

private String bytesToHex(byte[] bytes) {
    StringBuilder sb = new StringBuilder();
    for (byte b : bytes) {
        String s = String.format("%02x", b);
        sb.append(s);
    }
    return sb.toString();
}

5.4.3.6. Google Play開発者サービスを利用したOpenSSLの脆弱性対策

Google Play開発者サービス(バージョン5.0以降)では、Provider Installerという仕組みが提供されている。これは、OpenSSLを含む暗号関連技術の実装であるSecurity Providerの脆弱性対策に利用できる。詳しくは「5.6.3.5. Google Play開発者サービスによるSecurity Providerの脆弱性対策」を参照のこと。

5.4.3.7. Network Security Configuration

Android 7.0(API Level 24)において、ネットワーク通信時のセキュリティ設定をアプリ毎に行うことができるNetwork Security Configurationが導入された。この仕組みを利用することにより、プライベート証明書でのHTTPS通信やピンニングによる証明書検証のほか、非暗号化(HTTP)通信の抑制、デバッグ時のみ有効なプライベート証明書の導入など、アプリのセキュリティを向上させる種々の施策をアプリに簡単に取り入れることができる [18]

[18]Network Security Configurationの詳細については、 https://developer.android.com/training/articles/security-config.html を参照すること。

Network Security Configurationの各種機能はxmlファイルの設定を行うだけで使用でき、アプリが行うHTTPおよびHTTPS通信全てに適用することができる。その結果、アプリのコードに修正や追加処理を行う必要がなくなるため、実装がシンプルになりバグや脆弱性の作り込み防止に効果があると考えられる。

プライベート証明書でHTTPS通信する

5.4.1.3. プライベート証明書でHTTPS通信する」で、私的に発行したサーバー証明書(プライベート証明書)でHTTPS通信をするためのサンプルコードを示した。Network Security Configurationを用いれば、開発者がプライベート証明書の検証処理を明示的に実装しなくても、「5.4.1.2. HTTPS通信する」のサンプルコードで示した通常のHTTPS通信と同じ実装でプライベート証明書を用いることができる。

特定ドメインへの通信時にプライベート証明書を用いる

    <?xml version="1.0" encoding="utf-8"?>
    <network-security-config>
        <domain-config>
            <domain includeSubdomains="true">jssec.org</domain>
            <trust-anchors>
                <certificates src="@raw/private_ca" />
            </trust-anchors>
        </domain-config>
    </network-security-config>

上記の例では、通信で使用するプライベート証明書(private_ca)をアプリ内にリソースとして保持しておき、それらを利用する条件や適用範囲をxmlファイルに記述している。<domain-config>タグを使用することで特定のドメインに対してのみプライベート証明書が適用される。アプリが行う全てのHTTPS通信に対してプライベート証明書を用いるためには、以下のように<base-config>タグを用いればよい。

アプリが行う全てのHTTPS通信時にプライベート証明書を用いる

    <?xml version="1.0" encoding="utf-8"?>
    <network-security-config>
        <base-config>
            <trust-anchors>
                <certificates src="@raw/private_ca" />
            </trust-anchors>
        </base-config>
    </network-security-config>
ピンニングによる検証

5.4.3.5. ピンニングによる検証の注意点と実装例」でピンニングによる証明書の検証について説明した。Network Security Configurationを用い以下のように設定すれば、コード上の検証処理が不要となり、xmlの記述だけで検証を行うことができる。

HTTPS通信時にピンニングによる検証を行う

    <?xml version="1.0" encoding="utf-8"?>
    <network-security-config>
        <domain-config>
            <domain includeSubdomains="true">jssec.org</domain>
            <pin-set expiration="2018-12-31">
                <pin digest="SHA-256">e30Lky+iWK21yHSls5DJoRzNikOdvQUOGXvurPidc2E=</pin>
                <!-- バックアップ用 -->
                <pin digest="SHA-256">fwza0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM1oE=</pin>
            </pin-set>
        </domain-config>
    </network-security-config>

上記の<pin>タグに記述するのは、ピンニング検証の対象となる公開鍵のハッシュ値をbase64でエンコードしたものである。また、ハッシュ関数はSHA-256のみサポートされている。

非暗号化(HTTP)通信の抑制

Network Security Configurationを用いて、アプリのHTTP通信(非暗号化通信)を抑制することができる。

非暗号化通信を抑制する方法は次の通りである:

  1. 基本的には、<base-config> タグにより、すべてのドメインとの通信で非暗号化通信(HTTP通信)を抑制する [19]
  2. やむを得えず非暗号化通信が必要なドメインのみ <domain-config> タグにより個別に非暗号化通信を許可する例外設定をおこなう。なお、非暗号化通信を許可して良いかどうかの判断には「5.4.1.1. HTTP通信する」を参考にすること
[19]HTTP以外の通信方式に対してどのような制御が行われるかについては、 https://developer.android.com/reference/android/security/NetworkSecurityPolicy.html#isCleartextTrafficPermitted を参照すること。

非暗号化通信の抑制は cleartextTrafficPermitted属性を falseに設定することで行われる。下にこの例を示す。

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <!-- 非暗号化通信はデフォルトで許可しない -->
    <base-config cleartextTrafficPermitted="false">
    </base-config>
    <!-- 例外的に非暗号化通信を許可する必要のあるサイトを、
     <domain-config> タグにより明示的に "true" に設定する -->
    <domain-config cleartextTrafficPermitted="true">
       <domain includeSubdomains="true">www.jssec.org</domain>
    </domain-config>
</network-security-config>

この設定はAndroid 8.0 (API Level 26)以降ではWebView にも適用されるが、Android 7.1 (API Level 25)以前ではWebView には適用されないことに注意する必要がある。

Android 9.0 (API Level 28)未満では、属性 cleartextTrafficPermitted のデフォルト値が true であったが、Android 9.0 以降では false となった。従って API Level 28 以降をターゲットとしている場合は上の例の <base-config>での宣言は不要である。しかし意図を明確にし、またターゲット API Level による挙動の違いの影響を避けるため、上の例のように明示的に記載することを推奨する。

デバッグ専用のプライベート証明書

アプリ開発時にデバッグ目的でプライベート証明書を用いた開発用サーバーとのHTTPS通信を行う場合、開発者は「5.4.3.3. 証明書検証を無効化する危険なコード」で述べたような、証明書検証を無効化させる危険な実装をアプリに組み込んでしまわないよう注意する必要がある。Network Security Configurationで以下のような設定を行えば、デバッグ時にのみ(AndroidManifest.xml内のandroid:debuggableが"true"である場合のみ)使用する証明書を指定することができるため、前述のような危険なコードを製品版に残してしまう危険性がなくなり、脆弱性の作り込み防止に役立てることができる。

デバッグ時にのみプライベート証明書を用いる

    <?xml version="1.0" encoding="utf-8"?>
    <network-security-config>
        <debug-overrides>
            <trust-anchors>
                <certificates src="@raw/private_cas" />
            </trust-anchors>
        </debug-overrides>
    </network-security-config>

5.4.3.8. (コラム) セキュア接続のTLS1.2/TLS1.3への移行について

最初の公開版である1994年リリースのSSL2 にはセキュリティプロトコルとして重大な脆弱性があったため、根本から設計し直したSSL3.0(RFC6101)が1995年後半にリリースされた。 しかし、2014年にGoogle Security Teamが発表したPOODLEと呼ばれる脆弱性 [20] によって、SSL3.0のパディングは安全ではないことが判明した。 1999年にリリースされたTLS1.0(RFC2246)ではパディングの設計上の欠陥は修正されているが、暗号化されたデータの一部を抜き出す攻撃手法BEASTが、DuongとRizzoにより2011年に発表された [21] 。 セキュリティに関する修正(TLS1.0の安全性強化)を行なったTLS1.1(RFC4346)が2006年にリリースされ、更に2008年にリリースされたTLS1.2(RFC5246)では ハッシュ関数SHA-2(SHA-256やSHA-384)の利用、認証付暗号(AEAD)利用モード(GCM, CCM)が利用可能な暗号スイートのサポートなど、より強力な暗号アルゴリズムの利用が可能になっている。

このような背景から米国立標準技術研究所(NIST)は、2018年10月15日発行のTLSに関するガイドライン [22] においてTLS1.1以前を非推奨あるいは禁止し、政府系だけではなく政府系以外のアプリケーションをサポートするサーバもTLS1.2への移行を求めている。それにともない、 ここ数年のセキュリティ騒動や新しいバージョンのTLSの普及から「SSLやTLSの古いバージョン」のサポートを取りやめるサイトやサービスも増えており、TLS1.2への移行が確実に進みつつある [23]

移行を要件としている例として、クレジットカードセキュリティの国際標準化団体 PCI SSCによって策定された「ペイメントカード業界データセキュリティ基準(PCI DSS: Payment Card Industry Data Security Standard)」と呼ばれるセキュリティ基準(最新版は2018年5月公開の v3.2.1)が挙げられる [24] 。 現在Eコマースはスマートフォンやタブレットでも広く利用されており、決済にはクレジットカードを利用する事が普通であろう。 本書(Androidアプリのセキュア設計・セキュアコーディングガイド)を利用するユーザーにおいても、アプリケーションを介してクレジットカード情報などをサーバサイドに送信するサービスを企画するケースも多いと予想するが、ネットワークでクレジットカードを利用する際にはその経路の安全性を確保することが必要で、PCI DSSは、このようなサービスにおける会員データを取り扱う際の基準であり、カードの不正利用や情報漏えいなどを防止する目的で定められている。 このセキュリティ基準の中 では具体的なバージョンは明記していないが、SSLの全てのバージョンおよび既知のエクスプロイト(攻撃プログラム)に対して脆弱なTLSの初期バージョンを2018年6月30日までで廃止とし、安全とされるバージョン(すなわちTLS1.2以降)にアップグレードすることを要求している 。

[20]“This POODLE bites: exploiting the SSL 3.0 fallback”(Google Security Team, October 14, 2014) (https://googleonlinesecurity.blogspot.co.uk/2014/10/this-poodle-bites-exploiting-ssl-30.html)
[21]“Here come the ⊕ Ninjas”(Thai Duong, Juliano Rizzo, May 13, 2011) (http://www.hit.bme.hu/%7Ebuttyan/courses/EIT-SEC/abib/04-TLS/BEAST.pdf)
[22]“Guidelines for the Selection, Configuration, and Use of Transport Layer Security (TLS) Implementations” (Rvision 2, October 2018) (https://csrc.nist.gov/CSRC/media/Publications/sp/800-52/rev-2/draft/documents/sp800-52r2-draft2.pdf)
[23]SSL/TLS 暗号設定ガイドライン, IPA (https://www.ipa.go.jp/security/vuln/ssl_crypt_config.html)
[24]“Requirements and Security Assessment Procedures” (Version 3.2.1, May 2018) (https://ja.pcisecuritystandards.org/document_library)

スマートフォンとサーバー間の通信において、経路上の安全を確保することは、クレジット情報の取り扱いに限らずプライバシー情報のやりとりやその他情報のやりとりにおいても非常に重要な点であると言えるため、サービスを提供する(サーバー)側がTLS1.2を利用したセキュア接続へ移行する事は差し迫った課題であるといえる。

一方、クライアント側であるAndroidにおいてTLS 1.1以降に対応できるWebView機能はAndroid 4.4(Kitkat)以降であり、HTTP通信を直接行う場合は多少追加実装を伴うがAndroid 4.1(Jelly Bean前期)からとなる。

サービス開発者の中には、TLS 1.2を採用するとAndroid 4.3以前のユーザーを切り捨てることになり、少なからず影響があると思われる向きがあるかもしれない。しかしながら、下図の通り、最新のデータ(2019年5月現在) [25] によると現在利用されているAndroid OSのシェアは4.4以降が96.2%と圧倒的であるため、扱う資産などの安全性を考慮して、TLS 1.2への移行を真剣に検討されることをお薦めする。

_images/android_os_share_201905.png

図 5.4.5 Android OS のシェア (Andorid Developpers サイトより)

[25]Distribution dashboard - Platform versions (https://developer.android.com/about/dashboards/index.html)

2018年8月に公開されたTLS1.3(RFC8446)は、TLS1.2策定以降に見つかった新たな脆弱性や攻撃手法への対策および性能向上を狙いとして、プロトコルと暗号アルゴリズムの抜本的な再設計が行われた [26] 。 Android 10ではプラットフォームのTLS実装がTLS 1.3をサポートするようになり、すべてのTLS接続に対してデフォルトでTLS 1.3が有効になった [27] 。 なお、Android 10からは、安全性の低い下記の暗号スイートが削除された(モード:CBC、MAC:SHA2) [28]

  • TLS_RSA_WITH_AES_128_CBC_SHA256
  • TLS_RSA_WITH_AES_256_CBC_SHA256
  • TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
  • TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384
  • TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256
  • TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384
[26]The Transport Layer Security (TLS) Protocol Version 1.3 (https://datatracker.ietf.org/doc/rfc8446/)
[27]Android Q features and APIs - TLS 1.3 support (https://developer.android.com/preview/features#tls-1.3)
[28]SHA-2 CBC cipher suites removed (https://developer.android.com/preview/behavior-changes-all#sha2-cbc-cipher-suites)

5.5. プライバシー情報を扱う

近年、プライバシー情報を守るための世界的な潮流として「プライバシー・バイ・デザイン」が提唱されており、この概念に基づき各国政府においてもプライバシー保護のための法制化を進めているところである。

スマートフォン内の利用者情報を活用するアプリは、利用者が個人情報やプライバシーの観点から安全・安心にアプリを活用できるように、利用者情報を適切に取り扱うとともに、利用者に対して分かりやすい説明を行い、利用者に利用の可否の選択を促すことが求められる。そのためには、アプリがどのような情報をどのように扱うか等を示したアプリ毎のアプリケーション・プライバシーポリシー(以下、アプリ・プライバシーポリシー)を作成・提示するとともに、慎重な取り扱いが求められる利用者情報の取得・利用については事前にユーザーの同意を得る必要がある。なお、アプリケーション・プライバシーポリシーは従来から存在する「個人情報保護方針」や「利用規約」等とは異なり別途作成を要するものであることに留意すること。

プライバシーポリシーの作成や運用に関して、詳しくは総務省が提唱する『スマートフォン プライバシー イニシアティブ』、『スマートフォン プライバシー イニシアティブⅡ』及び『スマートフォン プライバシーイニシアティブⅢ』(以下 総務省SPIと省略)を参照のこと。

また、本記事で扱う用語については、本文内の解説および「5.5.3.2. 用語解説」を参照すること。

5.5.1. サンプルコード

アプリ・プライバシーポリシーの作成には、一般に公開されている「アプリケーション・プライバシーポリシー作成支援ツール [29]」を利用することもできる。このツールの出力はHTML形式およびXML形式となっており、概要版アプリケーション・プライバシーポリシーと詳細版アプリケーション・プライバシーポリシーのそれぞれのファイルが作成される。作成されたXMLファイルには検査用のタグがつくなど、総務省SPIに準拠した形となっている。以下のサンプルコードでは、上記ツールを使って作成したHTMLファイルを利用してアプリ・プライバシーポリシーを提示する例を示す。

[29]http://www.kddi-research.jp/newsrelease/2013/090401.html
_images/image82.png

図 5.5.1 概要版アプリケーション・プライバシーポリシーの例

具体的には、次の判定フローに従うことで利用するサンプルコードを判断できる。

_images/image83.png

図 5.5.2 プライバシー情報を扱うサンプルコードを選択するフローチャート

ここで、包括同意とは、アプリ初回起動時のアプリ・プライバシーポリシーの提示・確認により、アプリがサーバーに送信する利用者情報について包括的に同意を得ることである。

また、個別同意とは、個々の利用者情報について送信の直前に個別の同意を得ることである。

5.5.1.1. [包括同意・個別同意あり] アプリ・プライバシーポリシーを組み込んだアプリ

ポイント:[包括同意・個別同意あり] アプリ・プライバシーポリシーを組み込んだアプリ

  1. 初回起動時(アップデート時)に、アプリが扱う利用者情報の送信について包括同意を得る
  2. ユーザーの包括同意が得られていない場合は、利用者情報の送信はしない
  3. 慎重な取り扱いが求められる利用者情報を送信する場合は、個別にユーザーの同意を得る
  4. ユーザーの個別同意が得られていない場合は、該当情報の送信はしない
  5. ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
  6. 送信した情報をユーザー操作により削除する手段を用意する
  7. ユーザー操作により利用者情報の送信を停止する手段を用意する
  8. 利用者情報の紐づけにはUUID/cookieを利用する
  9. アプリ・プライバシーポリシー概要版をassetsフォルダ内に配置しておく
MainActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.privacypolicy;

import java.io.IOException;
import org.json.JSONException;
import org.json.JSONObject;
import org.jssec.android.privacypolicy.ConfirmFragment.DialogListener;

import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
import com.google.android.gms.location.FusedLocationProviderClient;
import com.google.android.gms.location.LocationServices;
import com.google.android.gms.tasks.OnSuccessListener;

import android.Manifest;
import android.location.Location;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import androidx.core.app.ActivityCompat;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.core.content.ContextCompat;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;

public class MainActivity extends FragmentActivity implements  DialogListener {
    private static final String BASE_URL = "https://www.example.com/pp";
    private static final String GET_ID_URI = BASE_URL + "/get_id.php";
    private static final String SEND_DATA_URI = BASE_URL + "/send_data.php";
    private static final String DEL_ID_URI = BASE_URL + "/del_id.php";

    private static final String ID_KEY = "id";
    private static final String LOCATION_KEY = "location";
    private static final String NICK_NAME_KEY = "nickname";

    private static final String PRIVACY_POLICY_COMPREHENSIVE_AGREED_KEY =
        "privacyPolicyComprehensiveAgreed";
    private static final String PRIVACY_POLICY_DISCRETE_TYPE1_AGREED_KEY =
        "privacyPolicyDiscreteType1Agreed";
    private static final String PRIVACY_POLICY_PREF_NAME = "privacypolicy_preference";
    private static final int  MY_PERMISSIONS_REQUEST_ACCESS_FINE_LOCATION = 1;

    private String UserId = "";
    private FusedLocationProviderClient mFusedLocationClient;

    private final int DIALOG_TYPE_COMPREHENSIVE_AGREEMENT = 1;
    private final int DIALOG_TYPE_PRE_CONFIRMATION = 2;

    private static final int VERSION_TO_SHOW_COMPREHENSIVE_AGREEMENT_ANEW = 1;

    private TextWatcher watchHandler = new TextWatcher() {

        @Override
        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
        }

        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {
            boolean buttonEnable = (s.length() > 0);

            MainActivity.this.findViewById(R.id.buttonStart).setEnabled(buttonEnable);
        }

        @Override
        public void afterTextChanged(Editable s) {
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mFusedLocationClient =
            LocationServices.getFusedLocationProviderClient(this);

        if (Build.VERSION.SDK_INT >= 23) {
            // 位置情報取得のためには、API Level 23以上の場合パーミッションの確認が必要
            int permissionCheck =
                ContextCompat.checkSelfPermission(this,
                        Manifest.permission.ACCESS_FINE_LOCATION);
            if (permissionCheck != PackageManager.PERMISSION_GRANTED) {
                // パーミッションがないためユーザーの許可を得る
                ActivityCompat.requestPermissions(this,
                    new String[]{Manifest.permission.ACCESS_FINE_LOCATION},
                    MY_PERMISSIONS_REQUEST_ACCESS_FINE_LOCATION);
            }
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode,
                                           String permissions[], int[] grantResults) {
        switch (requestCode) {
            case MY_PERMISSIONS_REQUEST_ACCESS_FINE_LOCATION: {
                if (grantResults.length > 0
                    && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    // パーミッションが許可された
                    // ユーザー識別用IDをサーバーから取得する
                    new GetDataAsyncTask().execute();
                    findViewById(R.id.buttonStart).setEnabled(false);
                    ((TextView) findViewById(R.id.editTextNickname))
                        .addTextChangedListener(watchHandler);
                    int resultCode = GoogleApiAvailability
                                    .getInstance()
                                    .isGooglePlayServicesAvailable(this);
                    if (resultCode != ConnectionResult.SUCCESS) {
                        // Googleplay サービスが利用できない。サンプルアプリでは終了する。
                        finish();
                    }
                } else {
                    // パーミッションが許可されない。サンプルアプリでは終了する。
                    finish();
                }
            }
        }
    }

    @Override
    protected void onStart() {
        super.onStart();

        SharedPreferences pref =
                getSharedPreferences(PRIVACY_POLICY_PREF_NAME, MODE_PRIVATE);
        int privacyPolicyAgreed =
                pref.getInt(PRIVACY_POLICY_COMPREHENSIVE_AGREED_KEY, -1);
        if (privacyPolicyAgreed <= VERSION_TO_SHOW_COMPREHENSIVE_AGREEMENT_ANEW) {
            // ★ポイント1★ 初回起動時(アップデート時)に、アプリが扱う利用者情報の送信について包括同意を得る
            // アップデート時については新しい利用者情報を扱うようになった場合にのみ再度包括同意を得る必要がある。
            ConfirmFragment dialog =
                ConfirmFragment.newInstance(R.string.privacyPolicy,
                                            R.string.agreePrivacyPolicy,
                                            DIALOG_TYPE_COMPREHENSIVE_AGREEMENT);
            dialog.setDialogListener(this);
            FragmentManager fragmentManager = getSupportFragmentManager();
            dialog.show(fragmentManager, "dialog");
        }
    }

    public void onSendToServer(View view) {
        // 慎重な取り扱いが求められる利用者情報を送信について既に同意を得ているか確認する
        // 実際には送信する情報の種別毎に同意を得る必要があることに注意すること
        SharedPreferences pref =
            getSharedPreferences(PRIVACY_POLICY_PREF_NAME, MODE_PRIVATE);
        int privacyPolicyAgreed =
            pref.getInt(PRIVACY_POLICY_DISCRETE_TYPE1_AGREED_KEY, -1);
        if (privacyPolicyAgreed <= VERSION_TO_SHOW_COMPREHENSIVE_AGREEMENT_ANEW) {
            // ★ポイント3★ 慎重な取り扱いが求められる利用者情報を送信する場合は、個別にユーザーの同意を得る
            ConfirmFragment dialog =
                ConfirmFragment.newInstance(R.string.sendLocation,
                                            R.string.cofirmSendLocation,
                                            DIALOG_TYPE_PRE_CONFIRMATION);
            dialog.setDialogListener(this);
            FragmentManager fragmentManager = getSupportFragmentManager();
            dialog.show(fragmentManager, "dialog");
        } else {
            // 同意済みのため、送信処理を開始する
            onPositiveButtonClick(DIALOG_TYPE_PRE_CONFIRMATION);
        }
    }

    public void onPositiveButtonClick(int type) {
        if (type == DIALOG_TYPE_COMPREHENSIVE_AGREEMENT) {
            // ★ポイント1★ 初回起動時(アップデート時)に、アプリが扱う利用者情報の送信について包括同意を得る
            SharedPreferences.Editor pref =
                getSharedPreferences(PRIVACY_POLICY_PREF_NAME, MODE_PRIVATE).edit();
            pref.putInt(PRIVACY_POLICY_COMPREHENSIVE_AGREED_KEY, getVersionCode());
            pref.apply();
        } else if (type == DIALOG_TYPE_PRE_CONFIRMATION) {
            // ★ポイント3★ 慎重な取り扱いが求められる利用者情報を送信する場合は、個別にユーザーの同意を得る
            mFusedLocationClient.getLastLocation()
                .addOnSuccessListener(this, new OnSuccessListener<Location>() {
                @Override
                public void onSuccess(Location location) {
                    String nickname = ((TextView) findViewById(R.id.editTextNickname))
                                      .getText().toString();
                    if (location != null) {
                        String locationData =
                            "Latitude:" + location.getLatitude() + "," +
                            "Longitude:" + location.getLongitude();
                        Toast.makeText(MainActivity.this,
                                       this.getClass().getSimpleName() +
                                       "\n - nickname : " + nickname +
                                       "\n - location : " + locationData,
                                       Toast.LENGTH_SHORT).show();
                        new SendDataAsyncTack()
                            .execute(SEND_DATA_URI, UserId, locationData, nickname);
                    } else {
                        Toast.makeText(MainActivity.this,
                                       this.getClass().getSimpleName() +
                                       "\n - nickname : " + nickname +
                                       "\n - location : unavailable",
                                       Toast.LENGTH_SHORT).show();
                    }
                }
            });
            // 同意を得た旨、状態を保存する
            // 実際には送信する情報の種別毎に同意を得る必要があることに注意すること
            SharedPreferences.Editor pref =
                getSharedPreferences(PRIVACY_POLICY_PREF_NAME, MODE_PRIVATE).edit();
            pref.putInt(PRIVACY_POLICY_DISCRETE_TYPE1_AGREED_KEY, getVersionCode());
            pref.apply();
        }
    }

    public void onNegativeButtonClick(int type) {
        if (type == DIALOG_TYPE_COMPREHENSIVE_AGREEMENT) {
            // ★ポイント2★ ユーザーの包括同意が得られていない場合は、利用者情報の送信はしない
            // サンプルアプリではアプリケーションを終了する
            finish();
        } else if (type == DIALOG_TYPE_PRE_CONFIRMATION) {
            // ★ポイント4★ ユーザーの個別同意が得られていない場合は、該当情報の送信はしない
            // ユーザー同意が得られなかったので何もしない
        }
    }

    private int getVersionCode() {
        int versionCode = -1;
        PackageManager packageManager = this.getPackageManager();
        try {
            PackageInfo packageInfo =
                packageManager.getPackageInfo(this.getPackageName(),
                                              PackageManager.GET_ACTIVITIES);
            versionCode = packageInfo.versionCode;
        } catch (NameNotFoundException e) {
            // 例外処理は割愛
        }
        return versionCode;
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.main, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
        case R.id.action_show_pp:
            // ★ポイント5★ ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
            Intent intent = new Intent();
            intent.setClass(this, WebViewAssetsActivity.class);
            startActivity(intent);
            return true;
        case R.id.action_del_id:
            // ★ポイント6★ 送信した情報をユーザー操作により削除する手段を用意する
            new SendDataAsyncTack().execute(DEL_ID_URI, UserId);
            return true;
        case R.id.action_donot_send_id:
            // ★ポイント7★ ユーザー操作により利用者情報の送信を停止する手段を用意する
            // 利用者情報の送信を停止した場合、包括同意に関する同意は破棄されたものとする
            SharedPreferences.Editor pref =
                getSharedPreferences(PRIVACY_POLICY_PREF_NAME, MODE_PRIVATE).edit();
            pref.putInt(PRIVACY_POLICY_COMPREHENSIVE_AGREED_KEY, 0);
            pref.apply();
            // 本サンプルでは利用者情報を送信しない場合、ユーザーに提供する機能が無くなるため
            // この段階でアプリを終了する。この処理はアプリ毎の都合に合わせて変更すること。
            String message  = getString(R.string.stopSendUserData);
            Toast.makeText(MainActivity.this,
                           this.getClass().getSimpleName() + " - " + message,
                           Toast.LENGTH_SHORT).show();
            finish();
            return true;
        }
        return false;
    }

    private class GetDataAsyncTask extends AsyncTask<String, Void, String> {
        private String extMessage = "";

        @Override
        protected String doInBackground(String... params) {
            // ★ポイント8★ 利用者情報の紐づけにはUUID/cookieを利用する
            // 本サンプルではサーバー側で生成したIDを利用する
            SharedPreferences sp =
                getSharedPreferences(PRIVACY_POLICY_PREF_NAME, MODE_PRIVATE);
            UserId = sp.getString(ID_KEY, null);
            if (UserId == null) {
                // SharedPreferences内にトークンが存在しなため、サーバーからIDを取り寄せる。
                try {
                    UserId = NetworkUtil.getCookie(GET_ID_URI, "", "id");
                } catch (IOException e) {
                    // 証明書エラーなどの例外をキャッチする
                    extMessage = e.toString();
                }
                // 取り寄せたIDをSharedPreferencesに保存する。
                sp.edit().putString(ID_KEY, UserId).commit();
            }
            return UserId;
        }

        @Override
        protected void onPostExecute(final String data) {
            String status = (data != null) ? "success" : "error";
            Toast.makeText(MainActivity.this,
                this.getClass().getSimpleName() + " - " + status + " : " + extMessage,
                Toast.LENGTH_SHORT).show();
        }
    }

    private class SendDataAsyncTack extends AsyncTask<String, Void, Boolean> {
        private String extMessage = "";

        @Override
        protected Boolean doInBackground(String... params) {
            String url = params[0];
            String id = params[1];
            String location = params.length > 2 ? params[2] : null;
            String nickname = params.length > 3 ? params[3] : null;

            Boolean result = false;
            try {
                JSONObject jsonData = new JSONObject();
                jsonData.put(ID_KEY, id);
                if (location != null)
                    jsonData.put(LOCATION_KEY, location);
                if (nickname != null)
                    jsonData.put(NICK_NAME_KEY, nickname);
                NetworkUtil.sendJSON(url, "", jsonData.toString());
                result = true;
            } catch (IOException e) {
                // 証明書エラーなどの例外をキャッチする
                extMessage = e.toString();
            } catch (JSONException e) {
                extMessage = e.toString();
            }
            return result;
        }

        @Override
        protected void onPostExecute(Boolean result) {
            String status = result ? "Success" : "Error";
            Toast.makeText(MainActivity.this,
                this.getClass().getSimpleName() + " - " + status + " : " + extMessage,
                Toast.LENGTH_SHORT).show();
        }
    }
}
ConfirmFragment.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.privacypolicy;

import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
import androidx.fragment.app.DialogFragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.TextView;

public class ConfirmFragment extends DialogFragment {

    private DialogListener mListener = null;

    public static interface DialogListener {
        public void onPositiveButtonClick(int type);

        public void onNegativeButtonClick(int type);
    }

    public static ConfirmFragment newInstance(int title, int sentence, int type) {
        ConfirmFragment fragment = new ConfirmFragment();
        Bundle args = new Bundle();
        args.putInt("title", title);
        args.putInt("sentence", sentence);
        args.putInt("type", type);
        fragment.setArguments(args);
        return fragment;
    }

    @Override
    public Dialog onCreateDialog(Bundle args) {
        // ★ポイント1★ 初回起動時(アップデート時)に、アプリが扱う利用者情報の送信について包括同意を得る
        // ★ポイント3★ 慎重な取り扱いが求められる利用者情報を送信する場合は、個別にユーザーの同意を得る
        final int title = getArguments().getInt("title");
        final int sentence = getArguments().getInt("sentence");
        final int type = getArguments().getInt("type");

        LayoutInflater inflater = (LayoutInflater) getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        View content = inflater.inflate(R.layout.fragment_comfirm, null);
        TextView linkPP = (TextView) content.findViewById(R.id.tx_link_pp);
        linkPP.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                // ★ポイント5★ ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
                Intent intent = new Intent();
                intent.setClass(getActivity(), WebViewAssetsActivity.class);
                startActivity(intent);
            }
        });

        AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
        builder.setIcon(R.drawable.ic_launcher);
        builder.setTitle(title);
        builder.setMessage(sentence);
        builder.setView(content);

        builder.setPositiveButton(R.string.buttonOK,
                                  new DialogInterface.OnClickListener() {
            public void onClick(DialogInterface dialog, int whichButton) {
                if (mListener != null) {
                    mListener.onPositiveButtonClick(type);
                }
            }
        });
        builder.setNegativeButton(R.string.buttonNG,
                                  new DialogInterface.OnClickListener() {
            public void onClick(DialogInterface dialog, int whichButton) {
                if (mListener != null) {
                    mListener.onNegativeButtonClick(type);
                }
            }
        });

        Dialog dialog = builder.create();
        dialog.setCanceledOnTouchOutside(false);

        return dialog;
    }

    @Override
    public void onAttach(Activity activity) {
        super.onAttach(activity);
        if (!(activity instanceof DialogListener)) {
            throw new ClassCastException(activity.toString() +
                                         " must implement DialogListener.");
        }
        mListener = (DialogListener) activity;
    }

    public void setDialogListener(DialogListener listener) {
        mListener = listener;
    }
}
WebViewAssetsActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.privacypolicy;

import android.app.Activity;
import android.os.Bundle;
import android.webkit.WebSettings;
import android.webkit.WebView;

public class WebViewAssetsActivity extends Activity {
    // ★ポイント9★ アプリ・プライバシーポリシー概要版をassetsフォルダ内に配置しておく
    private static final String ABST_PP_URL =
            "file:///android_asset/PrivacyPolicy/app-policy-abst-privacypolicy-1.0.html";
        
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_webview);

        WebView webView = (WebView) findViewById(R.id.webView);
        WebSettings webSettings = webView.getSettings();

        webSettings.setAllowFileAccess(false);

        webView.loadUrl(ABST_PP_URL);
    }
}

5.5.1.2. [包括同意あり] アプリ・プライバシーポリシーを組み込んだアプリ

ポイント:[包括同意あり] アプリ・プライバシーポリシーを組み込んだアプリ

  1. 初回起動時(アップデート時)に、アプリが扱う利用者情報の送信について包括同意を得る
  2. ユーザーの包括同意が得られていない場合は、利用者情報の送信はしない
  3. ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
  4. 送信した情報をユーザー操作により削除する手段を用意する
  5. ユーザー操作により利用者情報の送信を停止する手段を用意する
  6. 利用者情報の紐づけにはUUID/cookieを利用する
  7. アプリ・プライバシーポリシー概要版をassetsフォルダ内に配置しておく
MainActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.privacypolicynopreconfirm;

import java.io.IOException;
import java.util.UUID;

import org.json.JSONException;
import org.json.JSONObject;
import org.jssec.android.privacypolicynopreconfirm.ConfirmFragment.DialogListener;

import android.os.AsyncTask;
import android.os.Bundle;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;

import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;

public class MainActivity extends FragmentActivity implements DialogListener {
    private final String BASE_URL = "https://www.example.com/pp";
    private final String GET_ID_URI = BASE_URL + "/get_id.php";
    private final String SEND_DATA_URI = BASE_URL + "/send_data.php";
    private final String DEL_ID_URI = BASE_URL + "/del_id.php";

    private final String ID_KEY = "id";
    private final String NICK_NAME_KEY = "nickname";
    private final String LN_KEY = "lineNumber";

    private final String PRIVACY_POLICY_AGREED_KEY = "privacyPolicyAgreed";

    private final String PRIVACY_POLICY_PREF_NAME = "privacypolicy_preference";

    private String mUUId = "";

    private String UserId = "";

    private final int DIALOG_TYPE_COMPREHENSIVE_AGREEMENT = 1;

    private final int VERSION_TO_SHOW_COMPREHENSIVE_AGREEMENT_ANEW = 1;

    private final int MY_PERMISSIONS_READ_PHONE_NUMBERS = 257;

    private TextWatcher watchHandler = new TextWatcher() {

        @Override
        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
        }

        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {
            boolean buttonEnable = (s.length() > 0);

            MainActivity.this.findViewById(R.id.buttonStart).setEnabled(buttonEnable);
        }

        @Override
        public void afterTextChanged(Editable s) {
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // ランダムに生成したUUIDを送る
        mUUId = UUID.randomUUID().toString();

        // ユーザー識別用IDをサーバーから取得する
        new GetDataAsyncTask().execute();
        findViewById(R.id.buttonStart).setEnabled(false);
        ((TextView) findViewById(R.id.editTextNickname))
            .addTextChangedListener(watchHandler);

    }

    @Override
    protected void onStart() {
        super.onStart();

        SharedPreferences pref = getSharedPreferences(PRIVACY_POLICY_PREF_NAME, MODE_PRIVATE);
        int privacyPolicyAgreed = pref.getInt(PRIVACY_POLICY_AGREED_KEY, -1);

        if (privacyPolicyAgreed <= VERSION_TO_SHOW_COMPREHENSIVE_AGREEMENT_ANEW) {
            // ★ポイント1★ 初回起動時(アップデート時)に、アプリが扱う利用者情報の送信について包括同意を得る
            // アップデート時については新しい利用者情報を扱うようになった場合にのみ再度包括同意を得る必要がある。
            ConfirmFragment dialog =
                ConfirmFragment.newInstance(R.string.privacyPolicy,
                                            R.string.agreePrivacyPolicy,
                                            DIALOG_TYPE_COMPREHENSIVE_AGREEMENT);
            dialog.setDialogListener(this);
            FragmentManager fragmentManager = getSupportFragmentManager();
            dialog.show(fragmentManager, "dialog");
        }
    }

    public void onSendToServer(View view) {
        String nickname =
            ((TextView) findViewById(R.id.editTextNickname)).getText().toString();
        Toast.makeText(MainActivity.this,
                  this.getClass().getSimpleName()
                       + "\n - nickname : "+ nickname + ", UUID = " + mUUId,
            Toast.LENGTH_SHORT).show();
        new SendDataAsyncTack().execute(SEND_DATA_URI, UserId, nickname, mUUId);
    }

    public void onPositiveButtonClick(int type) {
        if (type == DIALOG_TYPE_COMPREHENSIVE_AGREEMENT) {
            // ★ポイント1★ 初回起動時(アップデート時)に、アプリが扱う利用者情報の送信について包括同意を得る
            SharedPreferences.Editor pref =
                    getSharedPreferences(PRIVACY_POLICY_PREF_NAME, MODE_PRIVATE).edit();
            pref.putInt(PRIVACY_POLICY_AGREED_KEY, getVersionCode());
            pref.apply();
        }
    }

    public void onNegativeButtonClick(int type) {
        if (type == DIALOG_TYPE_COMPREHENSIVE_AGREEMENT) {
            // ★ポイント2★ ユーザーの包括同意が得られていない場合は、利用者情報の送信はしない
            // サンプルアプリではアプリケーションを終了する
            finish();
        }
    }

    private int getVersionCode() {
        int versionCode = -1;
        PackageManager packageManager = this.getPackageManager();
        try {
            PackageInfo packageInfo =
                packageManager.getPackageInfo(this.getPackageName(),
                                              PackageManager.GET_ACTIVITIES);
            versionCode = packageInfo.versionCode;
        } catch (NameNotFoundException e) {
            // 例外処理は割愛
        }

        return versionCode;
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.main, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case R.id.action_show_pp:
                // ★ポイント3★ ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
                Intent intent = new Intent();
                intent.setClass(this, WebViewAssetsActivity.class);
                startActivity(intent);
                return true;
            case R.id.action_del_id:
                // ★ポイント4★ 送信した情報をユーザー操作により削除する手段を用意する
                new SendDataAsyncTack().execute(DEL_ID_URI, UserId);
                return true;
            case R.id.action_donot_send_id:
                // ★ポイント5★ ユーザー操作により利用者情報の送信を停止する手段を用意する

                // 利用者情報の送信を停止した場合、包括同意に関する同意は破棄されたものとする
                SharedPreferences.Editor pref =
                    getSharedPreferences(PRIVACY_POLICY_PREF_NAME, MODE_PRIVATE).edit();
                pref.putInt(PRIVACY_POLICY_AGREED_KEY, 0);
                pref.apply();

                // 本サンプルでは利用者情報を送信しない場合、ユーザーに提供する機能が無くなるため
                // この段階でアプリを終了する。この処理はアプリ毎の都合に合わせて変更すること。
                String message  = getString(R.string.stopSendUserData);
                Toast.makeText(MainActivity.this,
                        this.getClass().getSimpleName() + " - " + message,
                        Toast.LENGTH_SHORT).show();
                finish();

                return true;                }
        return false;
    }

    private class GetDataAsyncTask extends AsyncTask<String, Void, String> {
        private String extMessage = "";

        @Override
        protected String doInBackground(String... params) {
            // ★ポイント6★ 利用者情報の紐づけにはUUID/cookieを利用する
            // 本サンプルではサーバー側で生成したIDを利用する
            SharedPreferences sp =
                getSharedPreferences(PRIVACY_POLICY_PREF_NAME, MODE_PRIVATE);
            UserId = sp.getString(ID_KEY, null);
            if (UserId == null) {
                // SharedPreferences内にトークンが存在しなため、サーバーからIDを取り寄せる。
                try {
                    UserId = NetworkUtil.getCookie(GET_ID_URI, "", "id");
                } catch (IOException e) {
                    // 証明書エラーなどの例外をキャッチする
                    extMessage = e.toString();
                }

                // 取り寄せたIDをSharedPreferencesに保存する。
                sp.edit().putString(ID_KEY, UserId).commit();
            }
            return UserId;
        }

        @Override
        protected void onPostExecute(final String data) {
            String status = (data != null) ? "success" : "error";
            Toast.makeText(MainActivity.this,
                this.getClass().getSimpleName() + " - " + status + " : " + extMessage,
                Toast.LENGTH_SHORT).show();
        }
    }

    private class SendDataAsyncTack extends AsyncTask<String, Void, Boolean> {
        private String extMessage = "";

        @Override
        protected Boolean doInBackground(String... params) {
            String url = params[0];
            String id = params[1];
            String nickname = params.length > 2 ? params[2] : null;
            String lineNum = params.length > 3 ? params[3] : null;

            Boolean result = false;
            try {
                JSONObject jsonData = new JSONObject();
                jsonData.put(ID_KEY, id);

                if (nickname != null)
                    jsonData.put(NICK_NAME_KEY, nickname);

                if (lineNum != null)
                    jsonData.put(LN_KEY, lineNum);

                NetworkUtil.sendJSON(url, "", jsonData.toString());

                result = true;
            } catch (IOException e) {
                // 証明書エラーなどの例外をキャッチする
                extMessage = e.toString();
            } catch (JSONException e) {
                extMessage = e.toString();
            }
            return result;
        }

        @Override
        protected void onPostExecute(Boolean result) {
            String status = result ? "Success" : "Error";
            Toast.makeText(MainActivity.this,
                this.getClass().getSimpleName() + " - " + status + " : " + extMessage,
                Toast.LENGTH_SHORT).show();
        }
    }
}
ConfirmFragment.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.privacypolicynopreconfirm;

import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
import androidx.fragment.app.DialogFragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.TextView;

public class ConfirmFragment extends DialogFragment {

    private DialogListener mListener = null;

    public static interface DialogListener {
        public void onPositiveButtonClick(int type);

        public void onNegativeButtonClick(int type);
    }

    public static ConfirmFragment newInstance(int title, int sentence, int type) {
        ConfirmFragment fragment = new ConfirmFragment();
        Bundle args = new Bundle();
        args.putInt("title", title);
        args.putInt("sentence", sentence);
        args.putInt("type", type);
        fragment.setArguments(args);
        return fragment;
    }

    @Override
    public Dialog onCreateDialog(Bundle args) {
        // ★ポイント1★ 初回起動時(アップデート時)に、アプリが扱う利用者情報の送信について包括同意を得る
        final int title = getArguments().getInt("title");
        final int sentence = getArguments().getInt("sentence");
        final int type = getArguments().getInt("type");

        LayoutInflater inflater = (LayoutInflater) getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        View content = inflater.inflate(R.layout.fragment_comfirm, null);
        TextView linkPP = (TextView) content.findViewById(R.id.tx_link_pp);
        linkPP.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                // ★ポイント3★ ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
                Intent intent = new Intent();
                intent.setClass(getActivity(), WebViewAssetsActivity.class);
                startActivity(intent);
            }
        });

        AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
        builder.setIcon(R.drawable.ic_launcher);
        builder.setTitle(title);
        builder.setMessage(sentence);
        builder.setView(content);

        builder.setPositiveButton(R.string.buttonOK,
                new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int whichButton) {
                    if (mListener != null) {
                        mListener.onPositiveButtonClick(type);
                    }
                }
            });
        builder.setNegativeButton(R.string.buttonNG,
                new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int whichButton) {
                    if (mListener != null) {
                        mListener.onNegativeButtonClick(type);
                    }
                }
            });

        Dialog dialog = builder.create();
        dialog.setCanceledOnTouchOutside(false);

        return dialog;
    }

    @Override
    public void onAttach(Activity activity) {
        super.onAttach(activity);
        if (!(activity instanceof DialogListener)) {
            throw new ClassCastException(activity.toString()
                                         + " must implement DialogListener.");
        }
        mListener = (DialogListener) activity;
    }

    public void setDialogListener(DialogListener listener) {
        mListener = listener;
    }
}
WebViewAssetsActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.privacypolicynopreconfirm;

import org.jssec.android.privacypolicynopreconfirm.R;

import android.app.Activity;
import android.os.Bundle;
import android.webkit.WebSettings;
import android.webkit.WebView;

public class WebViewAssetsActivity extends Activity {
    // ★ポイント7★ アプリ・プライバシーポリシー概要版をassetsフォルダ内に配置しておく
    private final String ABST_PP_URL =
            "file:///android_asset/PrivacyPolicy/app-policy-abst-privacypolicy-1.0.html";
        
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_webview);

        WebView webView = (WebView) findViewById(R.id.webView);
        WebSettings webSettings = webView.getSettings();

        webSettings.setAllowFileAccess(false);

        webView.loadUrl(ABST_PP_URL);
    }
}

5.5.1.3. [包括同意なし] アプリ・プライバシーポリシーを組み込んだアプリ

ポイント:[包括同意なし] アプリ・プライバシーポリシーを組み込んだアプリ

  1. ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
  2. 送信した情報をユーザー操作により削除する手段を用意する
  3. ユーザー操作により利用者情報の送信を停止する手段を用意する
  4. 利用者情報の紐づけにはUUID/cookieを利用する
  5. アプリ・プライバシーポリシー概要版をassetsフォルダ内に配置しておく
MainActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.privacypolicynocomprehensive;

import java.io.IOException;
import org.json.JSONException;
import org.json.JSONObject;

import android.os.AsyncTask;
import android.os.Bundle;
import android.content.Intent;
import android.content.SharedPreferences;
import androidx.fragment.app.FragmentActivity;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;

public class MainActivity extends FragmentActivity {
    private static final String BASE_URL = "https://www.example.com/pp";
    private static final String GET_ID_URI = BASE_URL + "/get_id.php";
    private static final String SEND_DATA_URI = BASE_URL + "/send_data.php";
    private static final String DEL_ID_URI = BASE_URL + "/del_id.php";

    private static final String ID_KEY = "id";
    private static final String NICK_NAME_KEY = "nickname";

    private static final String PRIVACY_POLICY_PREF_NAME = "privacypolicy_preference";

    private String UserId = "";

    private TextWatcher watchHandler = new TextWatcher() {

        @Override
        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
        }

        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {
            boolean buttonEnable = (s.length() > 0);

            MainActivity.this.findViewById(R.id.buttonStart).setEnabled(buttonEnable);
        }

        @Override
        public void afterTextChanged(Editable s) {
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // ユーザー識別用IDをサーバーから取得する
        new GetDataAsyncTask().execute();

        findViewById(R.id.buttonStart).setEnabled(false);
        ((TextView) findViewById(R.id.editTextNickname))
                                .addTextChangedListener(watchHandler);
    }

    public void onSendToServer(View view) {
        String nickname =
            ((TextView) findViewById(R.id.editTextNickname)).getText().toString();
        Toast.makeText(MainActivity.this,
            this.getClass().getSimpleName() + "\n - nickname : " + nickname,
            Toast.LENGTH_SHORT).show();
        new sendDataAsyncTack().execute(SEND_DATA_URI, UserId, nickname);
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.main, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
        case R.id.action_show_pp:
            // ★ポイント1★ ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
            Intent intent = new Intent();
            intent.setClass(this, WebViewAssetsActivity.class);
            startActivity(intent);
            return true;
        case R.id.action_del_id:
            // ★ポイント2★ 送信した情報をユーザー操作により削除する手段を用意する
            new sendDataAsyncTack().execute(DEL_ID_URI, UserId);
            return true;
        case R.id.action_donot_send_id:
            // ★ポイント3★ ユーザー操作により利用者情報の送信を停止する手段を用意する

            // 本サンプルでは利用者情報を送信しない場合、ユーザーに提供する機能が無くなるため
            // この段階でアプリを終了する。この処理はアプリ毎の都合に合わせて変更すること。
            String message  = getString(R.string.stopSendUserData);
            Toast.makeText(MainActivity.this,
                    this.getClass().getSimpleName() + " - " + message,
                    Toast.LENGTH_SHORT).show();
            finish();
                        
            return true;
        }
        return false;
    }

    private class GetDataAsyncTask extends AsyncTask<String, Void, String> {
        private String extMessage = "";

        @Override
        protected String doInBackground(String... params) {
            // ★ポイント4★ 利用者情報の紐づけにはUUID/cookieを利用する
            // 本サンプルではサーバー側で生成したIDを利用する
            SharedPreferences sp =
                    getSharedPreferences(PRIVACY_POLICY_PREF_NAME, MODE_PRIVATE);
            UserId = sp.getString(ID_KEY, null);
            if (UserId == null) {
                // SharedPreferences内にトークンが存在しなため、サーバーからIDを取り寄せる。
                try {
                    UserId = NetworkUtil.getCookie(GET_ID_URI, "", "id");
                } catch (IOException e) {
                    // 証明書エラーなどの例外をキャッチする
                    extMessage = e.toString();
                }

                // 取り寄せたIDをSharedPreferencesに保存する。
                sp.edit().putString(ID_KEY, UserId).commit();
            }
            return UserId;
        }

        @Override
        protected void onPostExecute(final String data) {
            String status = (data != null) ? "success" : "error";
            Toast.makeText(MainActivity.this,
                    this.getClass().getSimpleName() + " - " + status + " : " + extMessage,
                    Toast.LENGTH_SHORT).show();
        }
    }

    private class sendDataAsyncTack extends AsyncTask<String, Void, Boolean> {
        private String extMessage = "";

        @Override
        protected Boolean doInBackground(String... params) {
            String url = params[0];
            String id = params[1];
            String nickname = params.length > 2 ? params[2] : null;

            Boolean result = false;
            try {
                JSONObject jsonData = new JSONObject();
                jsonData.put(ID_KEY, id);

                if (nickname != null)
                    jsonData.put(NICK_NAME_KEY, nickname);

                NetworkUtil.sendJSON(url, "", jsonData.toString());

                result = true;
            } catch (IOException e) {
                // 証明書エラーなどの例外をキャッチする
                extMessage = e.toString();
            } catch (JSONException e) {
                extMessage = e.toString();
            }
            return result;
        }

        @Override
        protected void onPostExecute(Boolean result) {
            String status = result ? "Success" : "Error";
            Toast.makeText(MainActivity.this,
                    this.getClass().getSimpleName() + " - " + status + " : " + extMessage,
                    Toast.LENGTH_SHORT).show();
        }
    }
}
WebViewAssetsActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.privacypolicynocomprehensive;

import org.jssec.android.privacypolicynocomprehensive.R;

import android.app.Activity;
import android.os.Bundle;
import android.webkit.WebSettings;
import android.webkit.WebView;

public class WebViewAssetsActivity extends Activity {
    // ★ポイント5★ アプリ・プライバシーポリシー概要版をassetsフォルダ内に配置しておく
    private static final String ABST_PP_URL =
            "file:///android_asset/PrivacyPolicy/app-policy-abst-privacypolicy-1.0.html";
        
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_webview);

        WebView webView = (WebView) findViewById(R.id.webView);
        WebSettings webSettings = webView.getSettings();

        webSettings.setAllowFileAccess(false);

        webView.loadUrl(ABST_PP_URL);
    }
}

5.5.1.4. アプリ・プライバシーポリシーを組み込まないアプリ

ポイント:アプリ・プライバシーポリシーを組み込まないアプリ

  1. 取得した情報を端末内部でのみ利用する場合、アプリ・プライバシーポリシーを表示しなくても良い
  2. マーケットプレイス等のアプリ説明欄に、取得した情報を外部送信しない旨を記載する
MainActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.privacypolicynoinfosent;

import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
import com.google.android.gms.location.FusedLocationProviderClient;
import com.google.android.gms.location.LocationServices;
import com.google.android.gms.tasks.OnSuccessListener;

import android.Manifest;
import android.location.Location;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.content.Intent;
import android.content.pm.PackageManager;
import androidx.core.app.ActivityCompat;
import androidx.fragment.app.FragmentActivity;
import androidx.core.content.ContextCompat;

import android.util.Log;
import android.view.Menu;
import android.view.View;

public class MainActivity extends FragmentActivity {
    private FusedLocationProviderClient mFusedLocationClient;

    private final int MY_PERMISSIONS_REQUEST_ACCESS_LOCATION = 257;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mFusedLocationClient = LocationServices.getFusedLocationProviderClient(this);
        if (Build.VERSION.SDK_INT >= 23) {
            // 位置情報取得のためには、API Level 23以上の場合パーミッションの確認が必要
            Boolean permissionCheck =
                (ContextCompat.checkSelfPermission(this,
                    Manifest.permission.ACCESS_FINE_LOCATION)
                    == PackageManager.PERMISSION_GRANTED);
            if (!permissionCheck) {
                // パーミッションがないためユーザーの許可を得る
                ActivityCompat.requestPermissions(this,
                        new String[]{Manifest.permission.ACCESS_FINE_LOCATION},
                        MY_PERMISSIONS_REQUEST_ACCESS_LOCATION);
            }
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode,
                                           String permissions[], int[] grantResults) {
        switch (requestCode) {
            case MY_PERMISSIONS_REQUEST_ACCESS_LOCATION: {
                if (grantResults.length > 0 && grantResults[0] ==
                    PackageManager.PERMISSION_GRANTED) {
                    // パーミッションが許可された
                    int resultCode =
                        GoogleApiAvailability.getInstance()
                                             .isGooglePlayServicesAvailable(this);
                    if (resultCode != ConnectionResult.SUCCESS) {
                        // Googleplay サービスが利用できない。サンプルアプリでは終了する。
                        finish();
                    }
                } else {
                    // パーミッションが許可されない。サンプルアプリでは終了する。
                    finish();
                }
            }
        }
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.main, menu);
        return true;
    }

    public void onStartMap(View view) {
        Log.d("onStartMap()"," called");
        // ★ポイント1★ 取得した情報を端末内部でのみ利用する場合、アプリ・プライバシーポリシーを表示しなくても良い
        mFusedLocationClient.getLastLocation()
            .addOnSuccessListener(this, new OnSuccessListener<Location>() {
            @Override
            public void onSuccess(Location location) {
                if (location != null) {
                    Intent intent =
                        new Intent(Intent.ACTION_VIEW,
                            Uri.parse("geo:" + location.getLatitude() +
                                      "," + location.getLongitude())
                    );
                    startActivity(intent);
                }
            }
        });
    }
}

★ポイント2★マーケットプレイス等のアプリ説明欄に、取得した情報を外部送信しない旨を記載する

_images/image84.png

図 5.5.3 マーケットプレイス上での説明例

5.5.2. ルールブック

プライバシー情報を扱う際には以下のルールを守ること。

  1. 送信する利用者情報は必要最低限に留める (必須)
  2. 初回起動時(アップデート時)に、ユーザーによる取り換えが困難、または、慎重な取り扱いが求められる利用者情報の送信について包括同意を得る (必須)
  3. 慎重な取り扱いが求められる利用者情報を送信する場合は、ユーザーに個別同意を得る (必須)
  4. ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する (必須)
  5. 利用者情報と紐づけるIDにはUUID/cookieを用いる(端末固有IDを用いない) (必須)
  6. 送信した利用者情報をユーザー操作により削除および送信停止する手段を用意する (推奨)
  7. 利用者情報を端末内のみで利用する場合、外部送信しない旨をユーザーに通知する (推奨)
  8. アプリ・プライバシーポリシー概要版をassetsフォルダ内に配置しておく (推奨)

5.5.2.1. 送信する利用者情報は必要最低限に留める (必須)

アプリマニュアルなどの説明を元にユーザーが想起できるアプリの動作および目的以外で利用者情報にアクセスしないよう設計すること。また、アプリの動作および目的に必要な利用者情報であっても、アプリ内部でのみ必要な情報と外部サーバー等に送信すべき情報を精査し、必要最低限の情報のみを送信すること。

例えば、アラームアプリで、指定した時間にアラームが鳴る機能しか提供していないにもかかわらず位置情報をサーバーに送信している場合、ユーザーは位置情報とアプリの機能を関連付けることは難しい。一方で、このアラームアプリが、ユーザーがいる場所に応じて設定されたアラームを鳴らす機能を持つ場合は、アプリが位置情報を利用する理由を説明することができる。このように、ユーザーが機能と利用者情報との関連を理解できる説明が可能であれば、利用者情報の利用に妥当性があるとみなせる。

5.5.2.2. 初回起動時(アップデート時)に、ユーザーによる取り換えが困難、または、慎重な取り扱いが求められる利用者情報の送信について包括同意を得る (必須)

ユーザーによる取り換えが困難な利用者情報や慎重な取り扱いが求められる利用者情報を外部サーバーに送信する場合、ユーザーがアプリを使用する前に、アプリがどのような情報をどのような目的でサーバーに送信するか、第三者提供はあるかなどについて、事前にユーザーの同意(オプトイン)を得ることが求められる。具体的には、アプリの初回起動時にアプリ・プライバシーポリシーを提示して、ユーザーの確認と同意を得るようにするべきである。また、アプリケーションのアップデートにより、新たな利用者情報を外部サーバーに送信するようになった時にも、再度ユーザーの確認と同意を得るようにする必要がある。同意が得られない場合は、アプリを終了するなど、情報の送信が必要な機能(処理)を無効にすること。

これにより、ユーザーが利用者情報の利用状況を理解した上でアプリを使用していることが保証され、ユーザーに安心感を提供するとともにアプリへの信頼感を得ることが期待できる。

MainActivity.java
protected void onStart() {
    super.onStart();
    
    // 〜省略〜
    if (privacyPolicyAgreed <= VERSION_TO_SHOW_COMPREHENSIVE_AGREEMENT_ANEW) {
        // ★ポイント★ 初回起動時(アップデート時)に、ユーザーによる取り換えが困難、または、
        // 慎重な取り扱いが求められる利用者情報の送信について包括同意を得る
        // アップデート時については、新しい利用者情報を扱うようになった場合にのみ、再度包括同意を得る必要がある。
        ConfirmFragment dialog =
		ConfirmFragment.newInstance(R.string.privacyPolicy,
					    R.string.agreePrivacyPolicy,
					    DIALOG_TYPE_COMPREHENSIVE_AGREEMENT);
        dialog.setDialogListener(this);
        FragmentManager fragmentManager = getSupportFragmentManager();
        dialog.show(fragmentManager, "dialog");
    }
_images/image85.png

図 5.5.4 包括同意の例

5.5.2.3. 慎重な取り扱いが求められる利用者情報を送信する場合は、ユーザーに個別同意を得る (必須)

慎重な取扱いが求められる利用者情報を外部サーバーに送信する場合は、包括同意に加えて、利用者情報(または利用者情報の送信を伴う機能)毎に、事前にユーザーの同意(オプトイン)を得ることが必要である。ユーザーの同意が得られなかった場合は、外部サーバーへの情報送信は実施してはならない。

これにより、ユーザーは包括同意で確認した利用者情報の送信に関して、より具体的なアプリ機能(提供サービス)との関連を知ることができ、アプリ提供者はユーザーのより正確な判断による同意を得ることが期待できる。

MainActivity.java
public void onSendToServer(View view) {
    // ★ポイント★慎重な取り扱いが求められる利用者情報を送信する場合は、ユーザーに個別同意を得る
    ConfirmFragment dialog =
        ConfirmFragment.newInstance(R.string.sendLocation,
                                    R.string.cofirmSendLocation,
                                    DIALOG_TYPE_PRE_CONFIRMATION);
    dialog.setDialogListener(this);
    FragmentManager fragmentManager = getSupportFragmentManager();
    dialog.show(fragmentManager, "dialog");
}
_images/image86.png

図 5.5.5 個別同意の例

5.5.2.4. ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する (必須)

一般に、ユーザーが該当アプリをインストールする前にアプリ・プライバシーポリシーを確認する手段として、Androidアプリのマーケットプレイス上にアプリ・プライバシーポリシーのリンクを表示する機能がある。この機能への対応に加え、アプリを端末にインストールした後でも、ユーザーがアプリ・プライバシーポリシーを参照できる手段を用意すること。特に利用者情報を外部サーバー等に送信する場合の個別同意確認時にはアプリ・プライバシーポリシーを容易に確認できる手段を用意し、ユーザーが正しく判断できるようにすることが望ましい。

MainActivity.java
    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
        case R.id.action_show_pp:
            // ★ポイント★ ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
            Intent intent = new Intent();
            intent.setClass(this, WebViewAssetsActivity.class);
            startActivity(intent);
            return true;
_images/image87.png

図 5.5.6 プライバシーポリシー表示用メニュー

5.5.2.5. 利用者情報と紐づけるIDにはUUID/cookieを用いる(端末固有IDを用いない) (必須)

IMEIなどの端末固有IDは、利用者情報と紐づけて送信すべきでない。端末固有IDと利用者情報が紐づいた形で一度でも公開や漏洩してしまうと、後から端末固有IDの変更が不可能なため、IDと利用者情報の紐づけを切ることができない(難しい)ことが、その理由である。また、Android 10 では、アプリのtargetSdkVersionに関わらず、IMEIなどの端末固有IDを取得できなくなっている。そのため、端末固有IDを用いず、UUID/cookieなどの乱数をベースにした都度作成する変更可能なIDを使って、利用者情報との紐づけおよび送信をすること。これにより、下記で説明する『忘れられる権利』を考慮した実装とすることもできる。

MainActivity.java
    @Override
    protected String doInBackground(String... params) {
        // ★ポイント★ 利用者情報の紐づけにはUUID/cookieを利用する
        // 本サンプルではサーバー側で生成したIDを利用する
        SharedPreferences sp =
            getSharedPreferences(PRIVACY_POLICY_PREF_NAME, MODE_PRIVATE);
        UserId = sp.getString(ID_KEY, null);
        if (UserId == null) {
            // SharedPreferences内にトークンが存在しなため、サーバーからIDを取り寄せる。
            try {
                UserId = NetworkUtil.getCookie(GET_ID_URI, "", "id");
            } catch (IOException e) {
                // 証明書エラーなどの例外をキャッチする
                extMessage = e.toString();
            }

            // 取り寄せたIDをSharedPreferencesに保存する。
            sp.edit().putString(ID_KEY, UserId).commit();
        
        return UserId;
    }

5.5.2.6. アプリ・プライバシーポリシー概要版をassetsフォルダ内に配置しておく (推奨)

アプリ・プライバシーポリシーの概要版はassetsフォルダ内に配置しておき、必要に応じて閲覧に利用することが望ましい。アプリ・プライバシーポリシーがassetsフォルダにあれば、いつでも容易にアクセス可能であると同時に、悪意のある第三者により、偽造や改造されたアプリ・プライバシーポリシーを参照させられるリスクが回避可能なためである。また、assetsフォルダに配置するファイルは、利用者情報に関する第三者検証が可能であることが望ましい。本節で紹介した「アプリケーション・プライバシーポリシー作成支援ツール」を利用する場合、検査用タグのあるXMLファイルもassetsフォルダに配置することで第三者検証が可能になる。

5.5.2.7. 送信した利用者情報をユーザー操作により削除および送信停止する手段を用意する (推奨)

利用者の求めに応じ、外部サーバー上に送信された利用者情報を削除する機能を提供することが望ましい。同様に、アプリ自身で端末内に利用者情報(もしくはそのコピー)を保存した場合も、その情報を削除する機能をユーザーに提供することが望ましい。また、利用者の求めに応じ、利用者情報の送信を停止する機能を提供することが望ましい。

本ルール(推奨)の内、利用者情報の削除については、EUで提唱されている『忘れられる権利』により定められたものであるが、今後は利用者データ保護に関する個人の権利の強化も提案されているため、特に理由がない限り、本ガイドでは利用者情報の削除機能を用意することを推奨する。また、利用者情報の送信停止については、主にブラウザによる対応が進んでいる『Do Not Track(追跡拒否)』の観点により定められたものであり、削除同様に送信停止機能を用意することを推奨する。

MainActivity.java
    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
        // 〜省略〜
        case R.id.action_del_id:
            // ★ポイント★ 送信した利用者情報をユーザー操作により削除および送信停止する手段を用意する 
            new SendDataAsyncTack().execute(DEL_ID_URI, UserId);
            return true;
        }

5.5.2.8. 利用者情報を端末内のみで利用する場合、外部送信しない旨をユーザーに通知する (推奨)

利用者の端末内部で一時的に利用者情報にアクセスするのみの場合であっても、利用者の理解を助け透明性を高めるために、その旨を伝えることが望ましい。具体的には、アクセスした利用者情報は、ある決まった目的のために端末内部で一時的に使用するのみであり、端末内に保存したり、外部サーバに送信したりしない旨をを利用者に通知すると良い。通知方法としては、マーケットプレイス上でのアプリ説明欄に記載するなどの方法が考えられる。なお、端末内での一時利用のみの場合は、アプリ・プライバシーポリシーへの記載は必須ではない。

_images/image88.png

図 5.5.7 マーケットプレイス上での説明例

5.5.3. アドバンスト

5.5.3.1. プライバシーポリシーを取りまく状況

スマートフォンにおける利用者情報を取得し、外部に送信する場合には、利用者に対してアプリがどのような情報をどのように扱うか等を示したアプリ・プライバシーポリシーを作成・提示する必要がある。アプリ・プライバシーポリシーに記載すべき内容は、総務省SPIにて定義されている。

アプリ・プライバシーポリシーは個々のアプリが扱うすべての利用者情報と、その用途や情報の保存先、送信先等を明らかにすることに主眼が置かれている。それとは別に事業者が個々のアプリから収集したすべての利用者情報をどのように保管・管理・廃棄をするかを示す事業者プライバシーポリシーも必要となる。従来、個人情報保護法をもとに作成されているプライバシーポリシーとはこの事業者プライバシーポリシーが相当する。

プライバシーポリシーの作成・提示方法やそれぞれのプラバシーポリシーの役割分担などは、『JSSEC スマホ・アプリのプライバシーポリシー作成・開示についての考察』(https://www.jssec.org/event/20140206/03-1_app_policy.pdf) に詳細説明が記載されているので参照のこと。

5.5.3.2. 用語解説

以下に、本ガイドで使用している用語について、『JSSEC スマホ・アプリのプライバシーポリシー作成・開示についての考察』 (https://www.jssec.org/event/20140206/03-1_app_policy.pdf) に記載されている用語解説を引用しておく。

表 5.5.1 用語解説
用語 解説
事業者プライバシーポリシー 事業者における個人情報保護方針を記したプライバシーポリシー。 個人情報保護法に基づき作成。
アプリ・プライバシーポリシー アプリケーション向けプライバシーポリシー。総務省SPIの指針に基づき作成。 概要版と詳細版での解りやすい説明が望まれる。
概要版アプリ・プライバシーポリシー アプリが取得する利用者情報、取得目的、第三者提供の有無などを簡潔に記述した文書。
詳細版アプリ・プライバシーポリシー 総務省SPIの定める8項目に準拠した詳細な内容を記述した文書。
利用者による取り換えが容易な利用者情報 cookie、UUIDなど。
利用者による取り換えが困難な利用者情報 IMEI、IMSI、ICCID、MACアドレス、OSが生成するIDなど。
慎重な取り扱いが求められる利用者情報 位置情報、アドレス帳、電話番号、メールアドレスなど。

5.5.3.3. Android IDのバージョンによる違いについて

Android ID(Settings.Secure.ANDROID_ID)は、ランダムに生成される64ビットの数値を16進数文字列で表現したものであり、(ごく稀に重複する可能性はあるが)端末を個別に識別することができる識別子である。そのため、使用法を間違えるとユーザーのトラッキングに繋がるリスクが大きくなり、使用の際には注意を必要とするが、Android 7.1(API Level 25)以前とAndroid 8.0(API Level26)以降の端末では、生成規則やアクセス可能範囲などが異なるため以下ではその違いについて説明する。

Android 7.1(API Level 25)以前の端末

Android 7.1(API Level 25)以前の端末では端末内に1つの値を持ちすべてのアプリからアクセス可能となる。ただし、マルチユーザーをサポートする端末ではユーザー毎に別の値が生成される。生成のタイミングとしては、最初は端末の工場出荷後の初回起動時であり、その後は、ファクトリーリセットの度に新しく生成される。

Android 8.0(API Level 26)以降の端末

Android 8.0(API Level 26)以降の端末ではアプリ(開発者)毎に異なる値を持ち、対象のアプリだけがアクセスできるように変更されている。具体的には、Android 7.1(API Level 25)以前のユーザーおよび端末に加えて、アプリの署名を要素として値が一意に決定される仕組みとなっており、署名の異なるアプリは別の(署名が同じ場合は同じ)Android IDの値を持つことになる。

値が変更されるタイミングに関しては以前とほぼ変わらないが、いくつか注意する点があるので以下に示す。

  • パッケージのアンインストール・再インストール時

Android IDの値は、署名が同じである限り、アプリをアンインストール・再インストールされても変わらない。逆に、署名する鍵が変わった場合は、パッケージ名が同じでも再インストール時にAndroid IDの値が異なることに注意する。

  • Android 8.0(API Level 26)以降の端末へのアップデート時

Android 7.1(API Level 25)以前の端末で既にアプリがインストールされていた場合、端末をAndroid 8.0(API Level 26)以降にアップデートしてもアプリで取得できるAndroid IDの値は変わらない。ただし、アップデート後にアプリがアンインストール・再インストールする場合は除く。

なお、いずれのAndroid IDでも「5.5.3.2. 用語解説」における「利用者による取り換えが困難な利用者情報」に分類されるため、冒頭に言及したように使用の際は同様の注意を払うことを推奨する。

5.5.3.4. Android 10 での再設定不可能なデバイスIDの取得制限について

Android 10 では、プライバシー保護のため、再設定不可能なデバイスIDの取得がより制限されることになった。取得するためには、READ_PRIVILEGED_PHONE_STATEパーミッションが必要であるが、このパーミッションは通常のアプリには付与されない。この変更は、targetSdkVersionの指定に限らず、Android 10 上で動く全てのアプリに影響する。そのため、通常パーミッションが付与され動作していたアプリでも、SecurityExceptionの発生やnullの返却により想定している挙動とならない可能性がある。以下に、そのような影響を受ける情報の種類と取得するためのAPIを示す。(READ_PHONE_STATEのような必要なパーミッションは付与されているものとする)

  • Buildクラス
表 5.5.2 Buildクラス
API 取得情報 targetSDKVersion=29 targetSDKVersion<29
(フィールド)BUILD.SERIAL デバイスのシリアル番号 Unknown Unknown
getSerial デバイスのシリアル番号 SecurityException Unknown
  • TelephonyManagerクラス
表 5.5.3 TelephomyManagerクラス
API 取得情報 targetSDKVersion=29 targetSDKVersion<29
getImei IMEI SecurityException null
getSubscriberId IMSI SecurityException null
getDeviceId IMEI、MEID、ESN SecurityException null
getMeid MEID SecurityException null
getSimSerialNumber SIM Serial SecurityException null

5.6. 暗号技術を利用する

セキュリティの世界では、脅威を分析し対策するための観点として、機密性(Confidentiality)、完全性(Integrity)、可用性(Availavility)という用語が使われる。それぞれ秘密のデータを第三者に見られないようにすること、参照するデータが改ざんされないように(または、改ざんされたことを検知)すること、サービスやデータが必要な時にいつでも利用できることを意味しており、セキュリティを確保する際に考慮すべき大切な要素である。中でも、機密性や完全性のために暗号技術が用いられることが多く、Androidにおいてもアプリが機密性や完全性を実現するための様々な暗号機能が用意されている。

ここでは、Androidアプリにおける暗号化・復号(機密性の実現)、メッセージ認証コード/デジタル署名 (完全性の実現)の安全な実装方法をサンプルコードで示す。

5.6.1. サンプルコード

「暗号化・復号する(機密性を確保する)」、「改ざんを検知する(完全性を確認する)」というユースケースの具体的な用途や条件に対して、様々な暗号方式が開発されている。ここでは、暗号技術をどのような用途で利用するかという観点から、暗号方式を大きく3つに分類してサンプルコードを用意している。それぞれの暗号技術の特徴から利用すべき暗号方式・鍵の種類を判断することができる。より細やかな判断が必要な場合には、「5.6.3.1. 暗号方式の選択」も参照すること。

また、暗号技術を利用する実装をする際には、「5.6.3.3. 乱数生成における脆弱性と対策」もあらかじめ参照すること。

  • 第三者の盗聴からデータを守る
_images/image89.png

図 5.6.1 盗聴からデータを守るサンプルコードを選択するフローチャート

  • 第三者によるデータの改ざんを検知する
_images/image90.png

図 5.6.2 データの改ざんを検知するサンプルコードを選択するフローチャート

5.6.1.1. パスワード鍵を利用して暗号化・復号する

ユーザーの情報資産の秘匿性を守る目的にはパスワードベース鍵暗号を使うことができる。

ポイント:

  1. 明示的に暗号モードとパディングを設定する
  2. 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
  3. パスワードから鍵を生成する場合は、Saltを使用する
  4. パスワードから鍵を生成する場合は、適正なハッシュの繰り返し回数を指定する
  5. 十分安全な長さを持つ鍵を利用する
AesCryptoPBEKey.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.jssec.android.cryptsymmetricpasswordbasedkey;

import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.util.Arrays;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;

public final class AesCryptoPBEKey {

    // ★ポイント1★ 明示的に暗号モードとパディングを設定する
    // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
    // Cipher クラスの getInstance に渡すパラメータ
    // (/[暗号アルゴリズム]/[ブロック暗号モード]/[パディングルール])
    // サンプルでは、暗号アルゴリズム=AES、ブロック暗号モード=CBC、パディングルール=PKCS7Padding
    private static final String TRANSFORMATION = "AES/CBC/PKCS7Padding";

    // 鍵を生成するクラスのインスタンスを取得するための文字列
    private static final String KEY_GENERATOR_MODE = "PBEWITHSHA256AND128BITAES-CBC-BC";

    // ★ポイント3★ パスワードから鍵を生成する場合は、Saltを使用する
    // Saltのバイト長
    public static final int SALT_LENGTH_BYTES = 20;

    // ★ポイント4★ パスワードから鍵を生成する場合は、適正なハッシュの繰り返し回数を指定する
    // PBE で鍵を生成する際の攪拌の繰り返し回数
    private static final int KEY_GEN_ITERATION_COUNT = 1024;

    // ★ポイント5★ 十分安全な長さを持つ鍵を利用する
    // 鍵のビット長
    private static final int KEY_LENGTH_BITS = 128;

    private byte[] mIV = null;
    private byte[] mSalt = null;

    public byte[] getIV() {
        return mIV;
    }

    public byte[] getSalt() {
        return mSalt;
    }

    AesCryptoPBEKey(final byte[] iv, final byte[] salt) {
        mIV = iv;
        mSalt = salt;
    }

    AesCryptoPBEKey() {
        mIV = null;
        initSalt();
    }

    private void initSalt() {
        mSalt = new byte[SALT_LENGTH_BYTES];
        SecureRandom sr = new SecureRandom();
        sr.nextBytes(mSalt);
    }

    public final byte[] encrypt(final byte[] plain, final char[] password) {
        byte[] encrypted = null;

        try {
            // ★ポイント1★ 明示的に暗号モードとパディングを設定する
            // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
            Cipher cipher = Cipher.getInstance(TRANSFORMATION);

            // ★ポイント3★ パスワードから鍵を生成する場合は、Saltを使用する
            SecretKey secretKey = generateKey(password, mSalt);
            cipher.init(Cipher.ENCRYPT_MODE, secretKey);
            mIV = cipher.getIV();

            encrypted = cipher.doFinal(plain);
        } catch (NoSuchAlgorithmException e) {
        } catch (NoSuchPaddingException e) {
        } catch (InvalidKeyException e) {
        } catch (IllegalBlockSizeException e) {
        } catch (BadPaddingException e) {
        } finally {
        }

        return encrypted;
    }

    public final byte[] decrypt(final byte[] encrypted, final char[] password) {
        byte[] plain = null;

        try {
            // ★ポイント1★ 明示的に暗号モードとパディングを設定する
            // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
            Cipher cipher = Cipher.getInstance(TRANSFORMATION);

            // ★ポイント3★ パスワードから鍵を生成する場合は、Saltを使用する
            SecretKey secretKey = generateKey(password, mSalt);
            IvParameterSpec ivParameterSpec = new IvParameterSpec(mIV);
            cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameterSpec);

            plain = cipher.doFinal(encrypted);
        } catch (NoSuchAlgorithmException e) {
        } catch (NoSuchPaddingException e) {
        } catch (InvalidKeyException e) {
        } catch (InvalidAlgorithmParameterException e) {
        } catch (IllegalBlockSizeException e) {
        } catch (BadPaddingException e) {
        } finally {
        }

        return plain;
    }

    private static final SecretKey generateKey(final char[] password, final byte[] salt) {
        SecretKey secretKey = null;
        PBEKeySpec keySpec = null;

        try {
            // ★ポイント2★ 脆弱でない(基準を満たす)アルゴリズム・モード・パディングを使用する
            // 鍵を生成するクラスのインスタンスを取得する
            // 例では、AES-CBC 128 ビット用の鍵を SHA256 を利用して生成する KeyFactory を使用。
            SecretKeyFactory secretKeyFactory =
                    SecretKeyFactory.getInstance(KEY_GENERATOR_MODE);

            // ★ポイント3★ パスワードから鍵を生成する場合は、Saltを使用する
            // ★ポイント4★ パスワードから鍵を生成する場合は、適正なハッシュの繰り返し回数を指定する
            // ★ポイント5★ 十分安全な長さを持つ鍵を利用する
            keySpec = new PBEKeySpec(password,
                    salt,
                    KEY_GEN_ITERATION_COUNT,
                    KEY_LENGTH_BITS);
            // passwordのクリア
            Arrays.fill(password, '?');
            // 鍵を生成する
            secretKey = secretKeyFactory.generateSecret(keySpec);
        } catch (NoSuchAlgorithmException e) {
        } catch (InvalidKeySpecException e) {
        } finally {
            keySpec.clearPassword();
        }

        return secretKey;
    }
}

5.6.1.2. 公開鍵を利用して暗号化・復号する

アプリ側では公開鍵を保持してデータの暗号化のみを行い、復号を異なる安全な場所(サーバーなど)で秘密鍵によって行うような用途には公開鍵暗号を使うことができる。

ポイント:

  1. 明示的に暗号モードとパディングを設定する
  2. 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
  3. 十分安全な長さを持つ鍵を利用する
RsaCryptoAsymmetricKey.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.cryptasymmetrickey;

import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;

public final class RsaCryptoAsymmetricKey {

    // ★ポイント1★ 明示的に暗号モードとパディングを設定する
    // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
    // Cipher クラスの getInstance に渡すパラメータ
    // (/[暗号アルゴリズム]/[ブロック暗号モード]/[パディングルール])
    // サンプルでは、暗号アルゴリズム=RSA、ブロック暗号モード=NONE、パディングルール=OAEPPADDING
    private static final String TRANSFORMATION = "RSA/NONE/OAEPPADDING";

    // 暗号アルゴリズム
    private static final String KEY_ALGORITHM = "RSA";

    // ★ポイント3★ 十分安全な長さを持つ鍵を利用する
    // 鍵長チェック
    private static final int MIN_KEY_LENGTH = 2000;

    RsaCryptoAsymmetricKey() {
    }

    public final byte[] encrypt(final byte[] plain, final byte[] keyData) {
        byte[] encrypted = null;

        try {
            // ★ポイント1★ 明示的に暗号モードとパディングを設定する
            // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
            Cipher cipher = Cipher.getInstance(TRANSFORMATION);

            PublicKey publicKey = generatePubKey(keyData);
            if (publicKey != null) {
                cipher.init(Cipher.ENCRYPT_MODE, publicKey);
                encrypted = cipher.doFinal(plain);
            }
        } catch (NoSuchAlgorithmException e) {
        } catch (NoSuchPaddingException e) {
        } catch (InvalidKeyException e) {
        } catch (IllegalBlockSizeException e) {
        } catch (BadPaddingException e) {
        } finally {
        }

        return encrypted;
    }

    public final byte[] decrypt(final byte[] encrypted, final byte[] keyData) {
        // 本来、復号処理はサーバー側で実装すべきものであるが、
        // 本サンプルでは動作確認用に、アプリ内でも復号処理を実装した。
        // 実際にサンプルコードを利用する場合は、アプリ内に秘密鍵を保持しないようにすること。
                
        byte[] plain = null;

        try {
            // ★ポイント1★ 明示的に暗号モードとパディングを設定する
            // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
            Cipher cipher = Cipher.getInstance(TRANSFORMATION);

            PrivateKey privateKey = generatePriKey(keyData);
            cipher.init(Cipher.DECRYPT_MODE, privateKey);

            plain = cipher.doFinal(encrypted);
        } catch (NoSuchAlgorithmException e) {
        } catch (NoSuchPaddingException e) {
        } catch (InvalidKeyException e) {
        } catch (IllegalBlockSizeException e) {
        } catch (BadPaddingException e) {
        } finally {
        }

        return plain;
    }

    private static final PublicKey generatePubKey(final byte[] keyData) {
        PublicKey publicKey = null;
        KeyFactory keyFactory = null;

        try {
            keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
            publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(keyData));
        } catch (IllegalArgumentException e) {
        } catch (NoSuchAlgorithmException e) {
        } catch (InvalidKeySpecException e) {
        } finally {
        }

        // ★ポイント3★ 十分安全な長さを持つ鍵を利用する
        // 鍵長のチェック
        if (publicKey instanceof RSAPublicKey) {
            int len = ((RSAPublicKey) publicKey).getModulus().bitLength();
            if (len < MIN_KEY_LENGTH) {
                publicKey = null;
            }
        }

        return publicKey;
    }

    private static final PrivateKey generatePriKey(final byte[] keyData) {
        PrivateKey privateKey = null;
        KeyFactory keyFactory = null;

        try {
            keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
            privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyData));
        } catch (IllegalArgumentException e) {
        } catch (NoSuchAlgorithmException e) {
        } catch (InvalidKeySpecException e) {
        } finally {
        }

        return privateKey;
    }
}

5.6.1.3. 共通鍵を利用して暗号化・復号する

サイズの大きなデータを扱う場合やアプリの資産・ユーザーの資産の秘匿性を守る目的で使うことができる。

ポイント:

  1. 明示的に暗号モードとパディングを設定する
  2. 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
  3. 十分安全な長さを持つ鍵を利用する
AesCryptoPreSharedKey.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.jssec.android.cryptsymmetricpresharedkey;

import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;

public final class AesCryptoPreSharedKey {

    // ★ポイント1★ 明示的に暗号モードとパディングを設定する
    // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
    // Cipher クラスの getInstance に渡すパラメータ
    // (/[暗号アルゴリズム]/[ブロック暗号モード]/[パディングルール])
    // サンプルでは、暗号アルゴリズム=AES、ブロック暗号モード=CBC、パディングルール=PKCS7Padding
    private static final String TRANSFORMATION = "AES/CBC/PKCS7Padding";

    // 暗号アルゴリズム
    private static final String KEY_ALGORITHM = "AES";

    // IVのバイト長
    public static final int IV_LENGTH_BYTES = 16;

    // ★ポイント3★ 十分安全な長さを持つ鍵を利用する
    // 鍵長チェック
    private static final int MIN_KEY_LENGTH_BYTES = 16;

    private byte[] mIV = null;

    public byte[] getIV() {
        return mIV;
    }

    AesCryptoPreSharedKey(final byte[] iv) {
        mIV = iv;
    }

    AesCryptoPreSharedKey() {
    }

    public final byte[] encrypt(final byte[] keyData, final byte[] plain) {
        byte[] encrypted = null;

        try {
            // ★ポイント1★ 明示的に暗号モードとパディングを設定する
            // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
            Cipher cipher = Cipher.getInstance(TRANSFORMATION);

            SecretKey secretKey = generateKey(keyData);
            if (secretKey != null) {
                cipher.init(Cipher.ENCRYPT_MODE, secretKey);
                mIV = cipher.getIV();

                encrypted = cipher.doFinal(plain);
            }
        } catch (NoSuchAlgorithmException e) {
        } catch (NoSuchPaddingException e) {
        } catch (InvalidKeyException e) {
        } catch (IllegalBlockSizeException e) {
        } catch (BadPaddingException e) {
        } finally {
        }

        return encrypted;
    }

    public final byte[] decrypt(final byte[] keyData, final byte[] encrypted) {
        byte[] plain = null;

        try {
            // ★ポイント1★ 明示的に暗号モードとパディングを設定する
            // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
            Cipher cipher = Cipher.getInstance(TRANSFORMATION);

            SecretKey secretKey = generateKey(keyData);
            if (secretKey != null) {
                IvParameterSpec ivParameterSpec = new IvParameterSpec(mIV);
                cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameterSpec);

                plain = cipher.doFinal(encrypted);
            }
        } catch (NoSuchAlgorithmException e) {
        } catch (NoSuchPaddingException e) {
        } catch (InvalidKeyException e) {
        } catch (InvalidAlgorithmParameterException e) {
        } catch (IllegalBlockSizeException e) {
        } catch (BadPaddingException e) {
        } finally {
        }

        return plain;
    }

    private static final SecretKey generateKey(final byte[] keyData) {
        SecretKey secretKey = null;

        try {
            // ★ポイント3★ 十分安全な長さを持つ鍵を利用する
            if (keyData.length >= MIN_KEY_LENGTH_BYTES) {
                // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
                secretKey = new SecretKeySpec(keyData, KEY_ALGORITHM);
            }
        } catch (IllegalArgumentException e) {
        } finally {
        }

        return secretKey;
    }
}

5.6.1.4. パスワード鍵を利用して改ざんを検知する

ユーザーの情報資産の完全性をチェックする目的にはパスワードベース(共通鍵)暗号を使うことができる。

ポイント:

  1. 明示的に暗号モードとパディングを設定する
  2. 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
  3. パスワードから鍵を生成する場合は、Saltを使用する
  4. パスワードから鍵を生成する場合は、適正なハッシュの繰り返し回数を指定する
  5. 十分安全な長さを持つ鍵を利用する
HmacPBEKey.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.signsymmetricpasswordbasedkey;

import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.util.Arrays;

import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;

public final class HmacPBEKey {

    // ★ポイント1★ 明示的に暗号モードとパディングを設定する
    // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
    // Mac クラスの getInstance に渡すパラメータ (認証モード)
    private static final String TRANSFORMATION = "PBEWITHHMACSHA1";

    // 鍵を生成するクラスのインスタンスを取得するための文字列
    private static final String KEY_GENERATOR_MODE = "PBEWITHHMACSHA1";

    // ★ポイント3★ パスワードから鍵を生成する場合は、Saltを使用する
    // Saltのバイト長
    public static final int SALT_LENGTH_BYTES = 20;

    // ★ポイント4★ パスワードから鍵を生成する場合は、適正なハッシュの繰り返し回数を指定する
    // PBE で鍵を生成する際の攪拌の繰り返し回数
    private static final int KEY_GEN_ITERATION_COUNT = 1024;

    // ★ポイント5★ 十分安全な長さを持つ鍵を利用する
    // 鍵のビット長
    private static final int KEY_LENGTH_BITS = 160;

    private byte[] mSalt = null;

    public byte[] getSalt() {
        return mSalt;
    }

    HmacPBEKey() {
        initSalt();
    }

    HmacPBEKey(final byte[] salt) {
        mSalt = salt;
    }

    private void initSalt() {
        mSalt = new byte[SALT_LENGTH_BYTES];
        SecureRandom sr = new SecureRandom();
        sr.nextBytes(mSalt);
    }

    public final byte[] sign(final byte[] plain, final char[] password) {
        return calculate(plain, password);
    }

    private final byte[] calculate(final byte[] plain, final char[] password) {
        byte[] hmac = null;

        try {
            // ★ポイント1★ 明示的に暗号モードとパディングを設定する
            // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
            Mac mac = Mac.getInstance(TRANSFORMATION);

            // ★ポイント3★ パスワードから鍵を生成する場合は、Saltを使用する
            SecretKey secretKey = generateKey(password, mSalt);
            mac.init(secretKey);

            hmac = mac.doFinal(plain);
        } catch (NoSuchAlgorithmException e) {
        } catch (InvalidKeyException e) {
        } finally {
        }

        return hmac;
    }

    public final boolean verify(final byte[] hmac,
                                final byte[] plain, final char[] password) {

        byte[] hmacForPlain = calculate(plain, password);

        if (Arrays.equals(hmac, hmacForPlain)) {
            return true;
        }
        return false;
    }

    private static final SecretKey generateKey(final char[] password, final byte[] salt) {
        SecretKey secretKey = null;
        PBEKeySpec keySpec = null;

        try {
            // ★ポイント2★ 脆弱でない(基準を満たす)アルゴリズム・モード・パディングを使用する
            // 鍵を生成するクラスのインスタンスを取得する
            // 例では、AES-CBC 128 ビット用の鍵を SHA1 を利用して生成する KeyFactory を使用。
            SecretKeyFactory secretKeyFactory =
                SecretKeyFactory.getInstance(KEY_GENERATOR_MODE);

            // ★ポイント3★ パスワードから鍵を生成する場合は、Saltを使用する
            // ★ポイント4★ パスワードから鍵を生成する場合は、適正なハッシュの繰り返し回数を指定する
            // ★ポイント5★ 十分安全な長さを持つ鍵を利用する
            keySpec =
                new PBEKeySpec(password, salt, KEY_GEN_ITERATION_COUNT, KEY_LENGTH_BITS);
            // passwordのクリア
            Arrays.fill(password, '?');
            // 鍵を生成する
            secretKey = secretKeyFactory.generateSecret(keySpec);
        } catch (NoSuchAlgorithmException e) {
        } catch (InvalidKeySpecException e) {
        } finally {
            keySpec.clearPassword();
        }

        return secretKey;
    }

}

5.6.1.5. 公開鍵を利用して改ざんを検知する

異なる安全な場所(サーバーなど)で秘密鍵による署名を行ったデータに対して、アプリ側では公開鍵を保持してデータの署名検証のみを行うような用途には公開鍵暗号を使うことができる。

ポイント:

  1. 明示的に暗号モードとパディングを設定する
  2. 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
  3. 十分安全な長さを持つ鍵を利用する
RsaSignAsymmetricKey.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.signasymmetrickey;

import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.security.SignatureException;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;

public final class RsaSignAsymmetricKey {

    // ★ポイント1★ 明示的に暗号モードとパディングを設定する
    // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
    // Cipher クラスの getInstance に渡すパラメータ
    // (/[暗号アルゴリズム]/[ブロック暗号モード]/[パディングルール])
    // サンプルでは、暗号アルゴリズム=RSA、ブロック暗号モード=NONE、パディングルール=OAEPPADDING
    private static final String TRANSFORMATION = "SHA256withRSA";
        
    // 暗号アルゴリズム
    private static final String KEY_ALGORITHM = "RSA";
        
    // ★ポイント3★ 十分安全な長さを持つ鍵を利用する
    // 鍵長チェック
    private static final int MIN_KEY_LENGTH = 2000;

    RsaSignAsymmetricKey() {
    }
        
    public final byte[] sign(final byte[] plain, final byte[] keyData) {
        // 本来、署名処理はサーバー側で実装すべきものであるが、
        // 本サンプルでは動作確認用に、アプリ内でも署名処理を実装した。
        // 実際にサンプルコードを利用する場合は、アプリ内に秘密鍵を保持しないようにすること。

        byte[] sign = null;

        try {
            // ★ポイント1★ 明示的に暗号モードとパディングを設定する
            // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
            Signature signature = Signature.getInstance(TRANSFORMATION);

            PrivateKey privateKey = generatePriKey(keyData);
            signature.initSign(privateKey);
            signature.update(plain);

            sign = signature.sign();
        } catch (NoSuchAlgorithmException e) {
        } catch (InvalidKeyException e) {
        } catch (SignatureException e) {
        } finally {
        }

        return sign;
    }

    public final boolean verify(final byte[] sign,
                                final byte[] plain, final byte[] keyData) {

        boolean ret = false;
                
        try {
            // ★ポイント1★ 明示的に暗号モードとパディングを設定する
            // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
            Signature signature = Signature.getInstance(TRANSFORMATION);

            PublicKey publicKey = generatePubKey(keyData);
            signature.initVerify(publicKey);                            
            signature.update(plain);
                        
            ret = signature.verify(sign);
                                                
        } catch (NoSuchAlgorithmException e) {
        } catch (InvalidKeyException e) {
        } catch (SignatureException e) {
        } finally {
        }

        return ret;
    }

    private static final PublicKey generatePubKey(final byte[] keyData) {
        PublicKey publicKey = null;
        KeyFactory keyFactory = null;
                
        try {
            keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
            publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(keyData));                     
        } catch (IllegalArgumentException e) {
        } catch (NoSuchAlgorithmException e) {
        } catch (InvalidKeySpecException e) {
        } finally {
        }

        // ★ポイント3★ 十分安全な長さを持つ鍵を利用する
        // 鍵長のチェック
        if (publicKey instanceof RSAPublicKey) {
            int len = ((RSAPublicKey) publicKey).getModulus().bitLength();
            if (len < MIN_KEY_LENGTH) {
                publicKey = null;
            }
        }

        return publicKey;
    }

    private static final PrivateKey generatePriKey(final byte[] keyData) {
        PrivateKey privateKey = null;
        KeyFactory keyFactory = null;
                
        try {
            keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
            privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyData));
        } catch (IllegalArgumentException e) {
        } catch (NoSuchAlgorithmException e) {
        } catch (InvalidKeySpecException e) {
        } finally {
        }

        return privateKey;
    }
}

5.6.1.6. 共通鍵を利用して改ざんを検知する

アプリの資産・ユーザーの資産の完全性をチェックする目的で使うことができる。

ポイント:

  1. 明示的に暗号モードとパディングを設定する
  2. 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
  3. 十分安全な長さを持つ鍵を利用する
HmacPreSharedKey.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.signsymmetricpresharedkey;

import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;

import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;

public final class HmacPreSharedKey {

    // ★ポイント1★ 明示的に暗号モードとパディングを設定する
    // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
    // Mac クラスの getInstance に渡すパラメータ (認証モード)
    private static final String TRANSFORMATION = "HmacSHA256";

    // 暗号アルゴリズム
    private static final String KEY_ALGORITHM = "HmacSHA256";

    // ★ポイント3★ 十分安全な長さを持つ鍵を利用する
    // 鍵長チェック
    private static final int MIN_KEY_LENGTH_BYTES = 16;

    HmacPreSharedKey() {
    }

    public final byte[] sign(final byte[] plain, final byte[] keyData) {
        return calculate(plain, keyData);
    }

    public final byte[] calculate(final byte[] plain, final byte[] keyData) {
        byte[] hmac = null;

        try {
            // ★ポイント1★ 明示的に暗号モードとパディングを設定する
            // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
            Mac mac = Mac.getInstance(TRANSFORMATION);

            SecretKey secretKey = generateKey(keyData);
            if (secretKey != null) {
                mac.init(secretKey);

                hmac = mac.doFinal(plain);
            }
        } catch (NoSuchAlgorithmException e) {
        } catch (InvalidKeyException e) {
        } finally {
        }

        return hmac;
    }

    public final boolean verify(final byte[] hmac,
                                final byte[] plain, final byte[] keyData) {
        byte[] hmacForPlain = calculate(plain, keyData);

        if (hmacForPlain != null && Arrays.equals(hmac, hmacForPlain)) {
            return true;
        }

        return false;
    }

    private static final SecretKey generateKey(final byte[] keyData) {
        SecretKey secretKey = null;

        try {
            // ★ポイント3★ 十分安全な長さを持つ鍵を利用する
            if (keyData.length >= MIN_KEY_LENGTH_BYTES) {
                // ★ポイント2★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
                secretKey = new SecretKeySpec(keyData, KEY_ALGORITHM);
            }
        } catch (IllegalArgumentException e) {
        } finally {
        }

        return secretKey;
    }
}

5.6.2. ルールブック

暗号技術を利用する際には以下のルールを守ること。

  1. 暗号を指定する場合は、明示的に暗号モードとパディングを設定する (必須)
  2. 脆弱でないアルゴリズム(基準を満たすもの)を使用する (必須)
  3. パスワードベース暗号のパスワードを端末内に保存しないこと (必須)
  4. パスワードから鍵を生成する場合は、Saltを使用する (必須)
  5. パスワードから鍵を生成する場合は、適正なハッシュの繰り返し回数を指定する (必須)
  6. パスワードの強度を高める工夫をする (推奨)

5.6.2.1. 暗号を指定する場合は、明示的に暗号モードとパディングを設定する (必須)

暗号化やデータ検証などで暗号技術を利用する場合、明示的に暗号モードとパディングを設定すること。Androidアプリの開発で暗号技術を利用する場合、主に java.crypto のクラスである Cipher クラスを利用する。Cipherクラスを利用する際、初めにどのような暗号を利用するかを指定してCipherクラスのオブジェクトを生成する。この指定をTransformationと呼ぶが、Transformationの指定の書式には以下の2種類が存在する

  • "algorithm/mode/padding"
  • "algorithm"

後者の場合、暗号モードとパディングは、Androidで利用可能な暗号化サービスプロバイダ固有のデフォルト値が使用される。このデフォルト値は、利便性や互換性などを優先し、あまり安全でないものが設定されていることがあり、セキュリティを確保するためには必ず、前者の暗号モードとパディングを指定する書式を利用する必要がある。

5.6.2.2. 脆弱でないアルゴリズム(基準を満たすもの)を使用する (必須)

暗号技術を利用する場合、アルゴリズムとして脆弱でない、一定の基準を満たしたものを使用すること。また、アルゴリズムとして複数の鍵長を許容する場合、アプリの製品ライフタイムを考慮して十分安全な鍵長を利用すること。さらには、暗号モードやパディングモードについても既知の攻撃方法が存在するものもあり、脆弱でない十分安全な方式を利用すること。

脆弱な暗号技術を利用した場合、例えば第三者からの盗聴を防ぐために暗号化したファイルでも、有効な保護になっておらず、第三者の盗聴を許してしまう為である。IT技術の進化に伴い、暗号解析の技術も向上されるため、利用する技術はアプリが稼働することを想定する期間中、安全が保護されることが期待できるアルゴリズムを検討し選択する必要がある。

アルゴリズムのライフタイム(特定の暗号化アルゴリズムによって保護されたデータが安全なままである推定期間)や暗号技術に関して、以下に示す各国の基準を参考にすること。

表 5.6.1 NIST, FIPS(米国) [30] [31] [32]
アルゴリズムのライフタイム 共通鍵暗号 公開鍵暗号(例:RSA, DSA, DH) 楕円曲線暗号(例:ECDSA) ハッシュ値(デジタル署名、ハッシュ関数) ハッシュ値(HMAC、鍵導出関数、乱数生成器)
レガシー(最低暗号強度80bit以下) 80 1024 160 160 -
~2030年(最低暗号強度112bit以上) 112 2048 224 224 -
2030年~(最低暗号強度128bit以上) 128 3072 256 256 160

単位:bit

[30]NIST Special Publication 800-57 Part1 Revision4(1/28/2016) “Recommendation for Key Management Part1:General” “5.6 Guidance for Cryptographic Algorithm and Key Size Selection” (https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt1r4.pdf)
[31]NIST Special Publication 800-131A Revision2(3/21/2019) “Transitioning the Use of Cryptographic Algorithms and Key Lengths” “1.1 Background and Purpose” “1.2.1 Security Strengths” (https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-131Ar2.pdf)
[32]Implementation Guidance for FIPS 140-2 and the Cryptographic Module Validation Program(Last Modified Date:05/10/2017) “7.5 Strength of Key Establishment Methods” (https://csrc.nist.gov/csrc/media/projects/cryptographic-module-validation-program/documents/fips140-2/fips1402ig.pdf)
表 5.6.2 ECRYPT II (EU) [33]
アルゴリズムのライフタイム 共通鍵暗号 公開鍵暗号 楕円曲線暗号 ハッシュ値
保護期間4年以内 80 1248 160 160
保護期間10年 96 1776 192 192
保護期間20年 112 2432 224 224
保護期間30年 128 3248 256 256
量子コンピュータに対する防御 256 15424 512 512

単位:bit

[33]“ECRYPT II Yearly Report on Algorithms and Keysizes(2011-2012)” (European Network of Excellence for Cryptology II, Revision 1.0, 30. Sept 2012) (http://www.ecrypt.eu.org/ecrypt2/documents/D.SPA.20.pdf)
表 5.6.3 CRYPTREC(日本) 電子政府推奨暗号リスト [34]
技術分類 名称
公開鍵暗号 署名 DSA,ECDSA,RSA=PSS,RSASSA=PKCS1=V1_5
守秘 RSA-OAEP
鍵共有 DH,ECDH
共通鍵暗号 64ビットブロック暗号 3-key Triple DES
128ビットブロック暗号 AES,Camellia
ストリーム暗号 KCipher-2
ハッシュ関数 SHA-256,SHA-384,SHA-512
暗号利用モード 秘匿モード CBC,CFB,CTR,OFB
認証付き秘匿モード CCM,GCM
メッセージ認証コード CMAC,HMAC
エンティティ認証 ISO/IEC 9798-2,ISO/IEC 9798-3
[34]https://www.cryptrec.go.jp/list.html

5.6.2.3. パスワードベース暗号のパスワードを端末内に保存しないこと (必須)

パスワードベース暗号で、ユーザーから入力を受けたパスワードをベースに暗号鍵を生成する場合、入力されたパスワードを端末内に保存しないこと。パスワードベース暗号の利点は暗号鍵の管理を不要とすることであり、端末内にパスワードを保存してしまうと、その利点が得られなくなるためである。もちろん、端末内にパスワードを保存することで、他のアプリから盗聴されてしまうリスクが生じることもあり、安全面から考えても端末内への保存はしてはならない。

5.6.2.4. パスワードから鍵を生成する場合は、Saltを使用する (必須)

パスワードベース暗号で、ユーザーから入力を受けたパスワードをベースに暗号鍵を生成する場合、必ずSaltを使用すること。また、端末内の別ユーザーに機能を提供する場合は、ユーザー毎に異なるSaltを利用すること。Saltを利用せずに単純なハッシュ関数のみで暗号鍵を生成した場合、レインボーテーブルと呼ばれる技術を使うことで、容易に元のパスワードを類推することができるためである。Saltを付けた場合、同じパスワードから生成した鍵であっても異なる鍵(ハッシュ値)が生成されるようになり、レインボーテーブルによる鍵の探索を妨害することができる。

パスワードから鍵を生成する場合に、Saltを使用する例

     public final byte[] encrypt(final byte[] plain, final char[] password) {
         byte[] encrypted = null;

         try {
             // ★ポイント★ 明示的に暗号モードとパディングを設定する
             // ★ポイント★ 脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
             Cipher cipher = Cipher.getInstance(TRANSFORMATION);

             // ★ポイント★ パスワードから鍵を生成する場合は、Saltを使用する
             SecretKey secretKey = generateKey(password, mSalt);

5.6.2.5. パスワードから鍵を生成する場合は、適正なハッシュの繰り返し回数を指定する (必須)

パスワードベース暗号で、ユーザーから入力を受けたパスワードをベースに暗号鍵を生成する場合、鍵生成で適用するハッシュ処理を十分安全な回数繰り返す(ストレッチングする)こと。一般に1,000回以上の繰り返しであれば良いとされる。さらに重要な資産を保護するために利用する場合は1,000,000回以上の繰り返しを行うこと。ハッシュ関数は一回の計算にかかる時間が非常に短時間であるため、攻撃者による総当たり攻撃が容易になる原因にもなっている。そのため、ハッシュ処理を繰り返し用いるストレッチング手法で、わざと時間がかかるようにして総当たり攻撃を困難にする。なお、ストレッチング回数はアプリの処理速度にも影響を及ぼすため、設定する回数には注意すること。

パスワードから鍵を生成する場合に、ハッシュの繰り返し回数を指定する例

     private static final SecretKey generateKey(final char[] password, final byte[] salt) {
         SecretKey secretKey = null;
         PBEKeySpec keySpec = null;

         // ~省略~

         // ★ポイント★ パスワードから鍵を生成する場合は、Saltを使用する
         // ★ポイント★ パスワードから鍵を生成する場合は、適正なハッシュの繰り返し回数を指定する
         // ★ポイント★ 十分安全な長さを持つ鍵を利用する
         keySpec = new PBEKeySpec(password,
                                  salt,
                                  KEY_GEN_ITERATION_COUNT,
                                  KEY_LENGTH_BITS);

5.6.2.6. パスワードの強度を高める工夫をする (推奨)

パスワードベース暗号で、ユーザーから入力を受けたパスワードをベースに暗号鍵を生成する場合、暗号鍵の強度はユーザーのパスワードの強度に直結するため、ユーザーから受け付けるパスワードの強度を高める工夫をすることが望ましい。例えば、パスワードの最低長を8文字以上としたり、複数の文字種の利用を必須としたり(英数記号を各1文字以上など)するなどの方法が考えられる。

5.6.3. アドバンスト

5.6.3.1. 暗号方式の選択

「サンプルコード」では、暗号化・復号、改ざん検知の各々について3つの暗号方式の実装例を示した。用途によってどの暗号方式を使用するかも、「図 5.6.1 盗聴からデータを守るサンプルコードを選択するフローチャート」、「図 5.6.2 データの改ざんを検知するサンプルコードを選択するフローチャート」を利用して大まかに判断することができる。一方で、暗号方式の選択は、その特徴を比較することでより細やかに判断することもできる。以下に比較を示す。

暗号化・復号における暗号方式の比較

公開鍵暗号は、処理コストが高いため大きなサイズのデータ処理には向かないが、暗号化と復号に使う鍵が異なるため、公開鍵のみアプリ側で扱い(暗号化のみ行い)、復号を別の(安全な)場所で行うような場合は鍵の管理が比較的容易である。共通鍵暗号は、制限の少ない万能な暗号方式であるが、暗号化・復号ともに同じ鍵を使用するため、アプリ内に鍵を安全に保持する必要があり、鍵の保護が難しい。パスワードベース暗号(パスワードを元にした共通鍵暗号)は、ユーザーのパスワードから鍵を生成するため、端末内に鍵に関する秘密を保存する必要がない。用途としては、ユーザーの資産を保護する場合のみに使える。また、暗号の強度がパスワードの強度に依存するため、資産の保護レベルに応じてパスワードを複雑にする必要がある。「5.6.2.6. パスワードの強度を高める工夫をする (推奨)」も参照すること。

表 5.6.4 暗号化・復号における暗号方式の比較
  公開鍵 共通鍵 パスワードベース
サイズの大きなデータの処理 NG(処理コストが高い) OK OK
アプリ(サービス)資産の保護 OK OK NG(ユーザーによる盗聴が可能)
ユーザー資産の保護 OK OK OK
暗号の強度 鍵の長さに依存 鍵の長さに依存 パスワードの強度、Salt、 ハッシュの繰り返し回数に依存
鍵の保護 容易(公開鍵のみ) 困難 容易
アプリで行う処理 暗号化(復号はサーバーなど) 暗号化・復号 暗号化・復号

改ざん検知における暗号方式の比較

データのサイズの項を除いた以外は、暗号化・復号における暗号方式の比較とほぼ同様である。

表 5.6.5 改ざん検知における暗号方式の比較
  公開鍵 共通鍵 パスワードベース
アプリ(サービス)資産の 保護 OK OK NG(ユーザーによる改ざんが 可能)
ユーザー資産の保護 OK OK OK
暗号の強度 鍵の長さに依存 鍵の長さに依存 パスワードの強度、Salt、 ハッシュの繰り返し回数に依存
鍵の保護 容易(公開鍵のみ) 困難(「5.6.3.4. 鍵の保護」参照) 容易
アプリで行う処理 署名検証(署名は サーバーなど) MAC計算・MAC検証 MAC計算・MAC検証

MAC:メッセージ認証コード

なお本ガイドでは、「3.1.3. 資産分類と保護施策」における資産レベル低中位の資産を主な保護対象としている。暗号の使用は鍵の管理問題など一般の保護施策(アクセス制御など)と比べて多くの検討事項を伴うため、Android OSのセキュリティモデルでは資産の保護を充分にできない場合のみ、暗号の使用を検討することが望ましい。

5.6.3.2. 乱数の生成

暗号技術の利用において、強固な暗号アルゴリズムや暗号モードを選択し、十分な長さの鍵を使用することは、アプリやサービスで扱うデータのセキュリティを確保する上で非常に重要である。しかし、これらの選択が適切であっても秘密の起点となる鍵が漏洩したり、予測されたりすると、アルゴリズム等で保証するセキュリティの強度が全く意味をなさなくなってしまう。また、AES等の共通暗号で使用する初期化ベクトル(IV)やパスワードベース暗号で使用するSalt(ソルト)に関しても、偏りが大きいと第三者による攻撃が容易になり、情報の漏洩や改ざんなどの被害に繋がる可能性が高くなる。このような事態を防ぐには、第三者に鍵やIVの値の予測が困難な方法で生成する必要があり、それを実現するために極めて重要な役割を果たすのが乱数である。乱数を生成する装置は乱数生成器と呼ばれ、センサー等を用いて予測・再現が不可能とされる自然状態を観測することで乱数を生成するハードウェアの乱数生成器(RNG)に対して、ソフトウェアで実現する乱数生成器を疑似乱数生成器(PRNG)と呼ぶのが一般的である。

Androidアプリでは、暗号用途での利用に対して十分セキュアな乱数をSecureRandomクラス経由で取得することができる。 SecureRandomクラスは、その機能を提供するProviderと呼ばれる実装を内部に複数持つことが可能であり、Providerを明示的に指定しない場合はデフォルトのProviderが選ばれる。 暗号学的に安全ではない [35] SHA1PRNG アルゴリズムを提供する Crypto Provider は、Android 7.0(API Level 24) で非推奨となり、Android 9.0(API Level 28)で削除された [36] [37] [38]。 Crypto Provider を指定してSecureRandomを使うと、Android 9.0 以降の端末では常に、Android 7.0 以降の端末でも targetSdkVersion>=24 であれば、NoSuchProviderException が発生する。 そのため、原則としてProviderを指定しないでSecureRandomを使うことを推奨する。以下にSecureRandomの使い方の例を示す。

[35]On statistical distance based testing of pseudo random sequences and experiments with PHP and Debian OpenSSL - 8.1 Java SHA1PRNG API based sequences (https://webpages.uncc.edu/yonwang/papers/lilesorics.pdf)
[36]Security "Crypto" provider deprecated in Android N (https://android-developers.googleblog.com/2016/06/security-crypto-provider-deprecated-in.html)
[37]Cryptography Changes in Android P (https://android-developers.googleblog.com/2018/03/cryptography-changes-in-android-p.html)
[38]SecureRandom (https://developer.android.com/reference/java/security/SecureRandom)

なお、SecureRandomにはAndroidのバージョンによっていくつか脆弱性があり、実装上の対策が必要になる。「5.6.3.3. 乱数生成における脆弱性と対策」を参照すること。

SecureRandomの使用(デフォルトの実装を使用する)

import java.security.SecureRandom;
// ~省略~
    SecureRandom random = new SecureRandom();
    byte[] randomBuf = new byte [128];
    
    random.nextBytes(randomBuf);
// ~省略~

SecureRandomのようなプログラムで実現されている疑似乱数生成器は一般に「図 5.6.3 疑似乱数生成器の内部プロセス」のように動作しており、乱数の種を入力して内部状態を初期化すると、乱数を生成する度に内部状態を一定のアルゴリズムで更新することで、次々と乱数列の生成が可能になる。

_images/image91.png

図 5.6.3 疑似乱数生成器の内部プロセス

乱数の種

疑似乱数生成器において乱数の種の役割は極めて重要である。

既に説明したように疑似乱数生成器は乱数の種による初期化が必要である。また、乱数の種で初期化された後は決められたアルゴリズムで乱数の生成が行われるので、同じ乱数の種からは同じ乱数列が生成されることになる。これは乱数の種が第三者に知られ(盗聴され)たり、予測可能であったりすると、第三者が同じ乱数列を取得することになり、乱数を起点にした機密性や完全性が失われることを意味している。

そのため、乱数の種はそれ自身機密性の高い情報であり、かつ予測困難でなければならない。例えば時刻情報や端末固有値(MACアドレス、IMEI、Android IDなど)を乱数の種として使うべきではない。多くのAndroid端末においては、/dev/urandom, /dev/random等が利用可能であり、デフォルトで提供されるSecureRandomの実装もそれらのデバイスファイルを使って乱数の種を設定している。また、機密性に関しては、乱数の種がメモリ内にのみ存在するならば、root権限を取得したマルウェア・ツールでない限り、盗聴の危険性は低い。仮に、root化された端末でも安全にする必要の場合は、セキュア設計・実装の専門家と相談して対応すること。

疑似乱数生成器の内部状態

疑似乱数生成器の内部状態は、乱数の種によって初期化され、乱数を生成する毎に状態が更新される。また、乱数の種と同様に、同じ内部状態の疑似乱数生成器が存在した場合は、その後に生成される乱数列はどちらもまったく同じものになる。よって、内部状態も第三者に対して、盗聴されないように気を付けなければならない。ただし、内部状態はメモリ内に存在するため、root権限を取得したマルウェア・ツールでない限り、盗聴の危険性は低い。root化された端末でも安全にする必要の場合は、セキュア設計・実装の専門家と相談して対応すること。

5.6.3.3. 乱数生成における脆弱性と対策

Android 4.3.x以前の"Crypto" ProviderのSecureRandom実装には、内部状態のエントロピー(Randomness)が充分に確保されないという不具合がある。特にAndroid 4.1.x以前は、SecureRandomの実装が"Crypto" Providerしかなく、SecureRandomを直接・間接的に使用するほとんどのアプリはこの脆弱性の影響を受ける。また、Android 4.2以降にSecureRandomのデフォルト実装となる"AndroidOpenSSL" Providerでは、OpenSSLが「乱数の種」として使うデータの大部分がアプリ間で共有されるという不具合(対象はAnroid 4.2.x-4.3.x)により、あるアプリが別のアプリの生成する乱数を推測しやすくなるという脆弱性を抱えている。以下にAndroid OSバージョンと各脆弱性の影響を受ける機能の整理しておく。

表 5.6.6 Android OS バージョンと各脆弱性の影響を受ける機能
  "Crypto" Provider SecureRandomの実装 別アプリのOpenSSLの乱数の種が推測可能
Android 4.1.x以前
  • SecureRandomのデフォルト実装
  • Crypto Providerの明示的な使用
  • Cipherクラスの提供する暗号機能
  • HTTPS通信機能 etc.
  • 影響なし
Android 4.2 - 4.3.x
  • Crypto Providerの明示的な使用
  • SecureRandomのデフォルト実装
  • AndroidOpenSSL Providerの明示的な使用
  • OpenSSLの乱数機能の直接使用
  • Cipherクラスの提供する暗号機能
  • HTTPS通信機能etc.
Android 4.4 - 6.0
  • 影響なし
  • 影響なし
Android 7.0 以降
  • Crypto Provider 廃止
  • 影響なし

2013年8月以降、これらAndroid OSの脆弱性を修正するパッチがGoogleからパートナー(端末メーカーなど)に配布されている。しかし、これらSecureRandomに関する脆弱性は暗号機能、HTTPS通信機能を含めて広い範囲に影響する上に、パッチが適用されていない端末が多数存在することも考えられるため、Android 4.3.x以前のOSを対象とするアプリでは、以下のサイトで紹介されている対策(実装)を組み込んでおくことをお薦めする。

https://android-developers.blogspot.jp/2013/08/some-securerandom-thoughts.html

5.6.3.4. 鍵の保護

センシティブな情報の安全性(機密性、完全性)を実現するために暗号技術を利用する際に、堅牢な暗号化アルゴリズムや鍵長を選んでも、鍵そのもののデータが簡単に取得できる状態では、第三者から情報の安全性を保つことはできない。そのため、暗号技術を利用する際の鍵の取り扱いは、もっとも重要な検討項目の一つである。一方で、守るべき資産のレベルによっては鍵の取り扱いは非常に高度な設計・実装を必要とするため本ガイドの範囲を超える。そこで本ガイドでは、鍵の配置場所と用途ごとに安全な鍵の取り扱いをするための基本的な考え方を示すにとどめ、具体的な実現方法に関しては言及しない。必要に応じてAndroidセキュリティ設計・実装に詳しい専門家に相談することをお勧めする。

まずは、Androidスマートフォン/タブレットにおいて暗号化等に使う鍵が存在する可能性のある場所とその保護方針の概観を「 図 5.6.4 暗号鍵が存在する場所と保護方針」に示す。

_images/image92.png

図 5.6.4 暗号鍵が存在する場所と保護方針

また、鍵によって守る資産の資産分類やオーナーによる保護方針の違いを表にまとめておく。資産レベルについては「3.1.3. 資産分類と保護施策」を参照すること。

表 5.6.7 資産分類と保護方針-1
資産のオーナー 端末のユーザー アプリ・サービス提供者
資産レベル 高位 中低位 高位 中低位
鍵の保存場所 保護方針
ユーザーの記憶 パスワードの強度の向上 ユーザーパスワードの利用不可
アプリディレクトリ (非公開ストレージ) 鍵データの 暗号化・難読化 アプリ以外から の読み書き禁止 鍵データの 暗号化・難読化 アプリ以外から の読み書き禁止

鍵の保存場所がAPKファイルやSDカードなどの公開ストレージの場合は次のようになる。

表 5.6.8 資産分類と保護方針-2
鍵の保存場所 保護方針
APKファイル 鍵データの難読化 (ProguardなどJavaの難読化ツールの多くは、データ(文字)列を難読化しないことに注意)
SDカードなど (公開ストレージ) 鍵データの暗号化・難読化

以下、鍵の存在場所ごとに保護方針の補足を示す。

ユーザーの記憶

この項ではパスワードベース暗号を想定している。パスワードから生成される鍵の場合は、鍵の保管場所はユーザーの記憶の中にあるため、保存場所からマルウェア等に漏洩することはない。ただし、パスワードの強度によっては簡単に鍵が再現されてしまうため、サービスへのログインパスワード等と同様にユーザーに対して一定のパスワード強度になるようにUIによって制限したり、メッセージによる注意喚起したりすることが必要となる。「5.5.2.6. アプリ・プライバシーポリシー概要版をassetsフォルダ内に配置しておく (推奨)」も参照すること。また、ユーザーの記憶にあるパスワードは忘れる可能性も考慮にいれておかなければならない。パスワードを忘れた場合でもデータの復元が必要な場合は、バックアップデータを端末以外の安全な場所(サーバーなど)に保存しておく必要がある。

アプリディレクトリ内

アプリのディレクトリ内にPrivateモードで保存した場合は他のアプリから鍵データを読み取られることはない。また、バックアップ機能をアプリで無効にした場合には、ユーザーも参照できなくなるので、アプリの資産を守る鍵をアプリディレクトリ内に保存する場合はバックアップを無効にすれば良い。

ただし、root権限を持つアプリやユーザーからも鍵を保護したい場合は、鍵データを暗号化もしくは難読化する必要がある。ユーザー資産を守る鍵を暗号化する場合にはパスワードベース暗号が利用できる。ユーザーにも秘密にしたいアプリの資産を暗号化する鍵を保護する場合には、APKファイルに鍵暗号化鍵を埋める必要があり、鍵暗号化鍵データを難読化する必要がある。

APKファイル内

APKファイル内に存在するデータは読み取り可能であるため、基本的には鍵のような秘匿データを含めるべきではない。鍵を含める場合は鍵データを難読化して、容易にAPKファイルから鍵データが取得されないように対策を講じる必要がある。

公開ストレージ(SDカードなど)内

公開ストレージはすべてのアプリから読み取り可能なため、基本的には鍵のような秘匿データを含めるべきではない。鍵を含める場合は鍵データを暗号化・難読化して容易に鍵データが取得されないように対策する必要がある。「アプリディレクトリ内」のroot権限を持つアプリやユーザーからも鍵を保護したい場合の対策も参照すること。

プロセスメモリ内の鍵の扱い

Androidの持つ暗号技術を使用する場合、上図におけるアプリプロセス以外の場所で暗号化もしくは難読化されていた鍵データは暗号処理の手前で必ず復号(パスワードベース鍵の場合は生成)が必要なため、プロセスメモリ内に鍵データが生のまま存在することになる。一方、アプリプロセスのメモリは通常別のアプリから読み書きすることはできないので、資産分類が本ガイドの対象範囲であれば、特別な対策を講じる必要性は少なく安全と言える。もし、アプリの扱う資産レベルや用途からプロセスメモリ内であっても鍵データがそのまま出現してはならない場合は、鍵データ・暗号ロジックの難読化など対策が必要であるが、一般にJava層での実現は難しく、JNI層での難読化ツールを使用することになる。これらの対応は本ガイドの範囲を超えるため、セキュア設計・実装の専門家と相談して対応すること。

5.6.3.5. Google Play開発者サービスによるSecurity Providerの脆弱性対策

Google Play開発者サービス(バージョン5.0以降)では、Provider Installerという仕組みが提供されており、Security Providerの脆弱性対策に利用できる。

まず、Security Providerとは、Java Cryptography Architecture(JCA)に基づいて各種暗号関連アルゴリズムの実装を提供するものである。Androidアプリで暗号技術を用いる際には、Cipher、Signature、Macといったクラスを通じてこのSecurity Providerを利用できる。一般に、暗号技術関連の実装で脆弱性が明らかになったときは迅速な対応が求められる。なぜなら脆弱性が悪用された際に大きな被害に繋がる可能性があるためである。Security Providerも暗号技術が関係するため、脆弱性への修正がいち早く反映されることが望ましい。

Security Providerの修正を反映する方法としては端末のアップデートが一般的である。端末のアップデートによる修正の反映は、メーカーがアップデートを用意し、ユーザーが用意されたアップデートを端末に適用するといったプロセスを経て行われる。そのため、アプリから、修正を含む最新の状態のSecurity Providerを使えるかどうかは、メーカーとユーザーの対応に左右される実態がある。それに対して、Google Play開発者サービスのProvider Installerを利用すると、自動更新されるSecurity Providerを使用できるようになる。

Google Play開発者サービスのProvider Installerでは、アプリからProvider Installerを呼び出すことで、Google Play開発者サービスが提供するSecurity Providerを使用できるようになる。Google Play開発者サービスは、Google Playストアを通じて自動的にアップデートされるので、Provider Installerで提供されるSecurity Providerも、メーカーとユーザーの対応状況に関わらず、最新の状態に更新されている。

以下に、Provider Installerを呼び出すサンプルコードを示す。

Provider Installerを呼び出す

import com.google.android.gms.common.GooglePlayServicesUtil;
import com.google.android.gms.security.ProviderInstaller;

public class MainActivity extends Activity
        implements ProviderInstaller.ProviderInstallListener {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        ProviderInstaller.installIfNeededAsync(this, this);
        setContentView(R.layout.activity_main);
    }

    @Override
    public void onProviderInstalled() {
        // Security Providerが最新、またはインストールが済んだときに呼ばれる
    }

    @Override
    public void onProviderInstallFailed(int errorCode, Intent recoveryIntent) {
        GoogleApiAvailability.getInstance().showErrorNotification(this, errorCode);

    }
}

5.7. 生体認証機能を利用する

生体認証の分野では、顔、声紋をはじめ、様々な方式が研究・開発されている。中でも、指紋認証は本人を特定する方法として古くから存在しており、署名(拇印)や犯罪捜査の目的で利用されてきた。コンピューターの世界においても様々な分野で応用が進み、近年ではスマートフォンの所有者識別(主に画面ロック解除)に採用されるなど、(入力の容易さなど)利便性の高い機能としての認識が浸透しつつある。

そのような流れを受けて、Android 6.0(API Level 23)では端末に指紋認証のフレームワークが組み込まれ、アプリから指紋認証(本人確認)機能(FingerprintManager)が利用できるようになった。さらに Android 9.0(API Level 28)では、指紋認証だけでなく、顔や虹彩認証などのそのほかの生体認証機能を統合的にサポートする BiometricPrompt APIが導入された。また、これまでアプリが独自で用意することが必要であった認証用のUIは不要になり、標準的な認証用ダイアログが自動的に使用されるようになっている。これに合わせて従来の指紋認証機能(FingerprintManager)は非推奨とされた。以下では、BiometricPromptによって生体認証を行う際のセキュリティ上の注意事項を記す。

5.7.1. サンプルコード

生体認証機能には、利用者の認証情報に紐づけた鍵を使用する場合と、単純に利用者の認証のみを行う場合の大きく二つのユースケースがある。これらの生体認証の用途に応じて、 図 5.7.1 に従いサンプルコードを選択すること。

_images/image95.png

図 5.7.1 生体認証を利用するサンプルコード選択フローチャート

Android 9.0(API Level 28)がリリースされた時点ではまだBiometricPromptのサポートライブラリの提供が無く、したがって利用できるのはAndroid 9.0搭載端末に限定されていた。しかし現在では サポートライブラリ androidx.biometricが提供されており、これによってAndroid 6.0以降のより広範な機種でBiometricPromptを利用することができるようになっている。以下に示すサンプルコードではサポートライブラリとして提供されているBiometricPromptを使用することとする。

5.7.1.1. 鍵と紐づいた認証を行う

以下に利用者の認証情報に紐づけた鍵を使う場合に生体認証機能をアプリから利用するためのサンプルコードを示す。

ポイント:

  1. USE_FINGERPRINT(Android 6.0~Android 8.1)または USE_BIOMETRIC(Android 9.0~)パーミッションを利用宣言する [39]
  2. "AndroidKeyStore" Provider からインスタンスを取得する
  3. 鍵を生成するためには生体情報の登録が必要である旨をユーザーに伝える
  4. 鍵生成(登録)時、暗号アルゴリズムは脆弱でないもの(基準を満たすもの)を使用する
  5. 鍵生成(登録)時、ユーザー(生体)認証の要求を有効にする(認証の有効期限は設定しない)
  6. 鍵を作る時点と鍵を使う時点で生体情報の登録状況が変わることを前提に設計を行う
  7. 暗号化するデータは、生体認証以外の手段で復元(代替)可能なものに限る
[39]サンプルコードで使用しているサポートライブラリandroidx.biometricのBiometricPromptはAndroid 6.0(API Level 23)~Android 8.1(API Level 27)デバイスではFingerPrintManagerの機能を使用して生体(指紋)認証を行うため、対応するUSE_FINGERPRINTパーミッションの利用宣言が必要である。対してAndroid 9.0(API Level 28)以降の端末ではandroid.hardware.biometricsのBiometricPromptの機能を使用しており、こちらはUSE_BIOMETRICパーミッションの利用宣言が必要となる(実際にはサポートライブラリパッケージandroidx.biometricのマニフェストファイルで、これらのパーミッションの利用宣言がされており、これを使用するアプリ側のマニフェストファイルで利用宣言をしなくとも問題なく動作する)。
MainActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.biometricprompt.cipher;

import androidx.appcompat.app.AlertDialog;
import androidx.biometric.BiometricPrompt;

import android.app.KeyguardManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.icu.text.SimpleDateFormat;
import android.os.Build;
import android.os.Bundle;
import android.util.Base64;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import java.util.Date;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;

public class MainActivity extends AppCompatActivity {
    private BiometricAuthentication mBiometricAuthentication;
    private static final String SENSITIVE_DATA = "sensitive date";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        if (!isBiometricEnabled(this)) {
            // ★ポイント3★ 鍵を生成するためには生体情報の登録が必要である旨をユーザーに伝える
            new AlertDialog.Builder(this)
                .setTitle(R.string.app_name)
                .setMessage("生体情報が登録されていません。 \n" +
                            "設定メニューの「セキュリティ」で生体情報を登録してください。\n" +
                            "生体情報を登録することにより、簡単に認証することができます。")
                .setPositiveButton("OK", null)
                .show();
            return;
        }

        // 指紋認証の結果を受け取るコールバック
        BiometricPrompt.AuthenticationCallback callback =
                new BiometricPrompt.AuthenticationCallback() {
            @Override
            public void onAuthenticationError(int errorCode, CharSequence errString) {
                showMessage(errString, R.color.colorError);
            }

            @Override
            public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
                Cipher cipher = result.getCryptoObject().getCipher();
                try {
                    // ★ポイント7★ 暗号化するデータは、指紋認証以外の手段で復元(代替)可能なものに限る
                    byte[] encrypted = cipher.doFinal(SENSITIVE_DATA.getBytes());
                    showEncryptedData(encrypted);
                } catch (IllegalBlockSizeException | BadPaddingException e) {
                }

                showMessage(getString(R.string.biometric_auth_succeeded),
                            R.color.colorAuthenticated);
                reset();
            }

            @Override
            public void onAuthenticationFailed() {
                showMessage(getString(R.string.biometric_auth_failed), R.color.colorError);
            }
        };

        mBiometricAuthentication = new BiometricAuthentication(this, callback);

        Button button_biometric_auth = findViewById(R.id.button_biometric_auth);
        button_biometric_auth.setOnClickListener(new View.OnClickListener () {
            @Override
            public void onClick(View v) {
                if (mBiometricAuthentication.startAuthentication()) {
                    showEncryptedData(null);
                }
            }
        });
    }

    private Boolean isBiometricEnabled(Context con) {
        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
            con.getSystemService(KeyguardManager.class).isKeyguardSecure() &&
            con.getPackageManager().hasSystemFeature(PackageManager.FEATURE_FINGERPRINT);
    }

    private void setAuthenticationState(boolean authenticating) {
        Button button = (Button) findViewById(R.id.button_biometric_auth);
        button.setText(authenticating ? R.string.cancel : R.string.authenticate);
    }

    private void showEncryptedData(byte[] encrypted) {
        TextView textView = (TextView) findViewById(R.id.encryptedData);
        if (encrypted != null) {
            textView.setText(Base64.encodeToString(encrypted, 0));
        } else {
            textView.setText("");
        }
    }

    private String getCurrentTimeString() {
        long currentTimeMillis = System.currentTimeMillis();
        Date date = new Date(currentTimeMillis);
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("HH:mm:ss.SSS");

        return simpleDateFormat.format(date);
    }

    private void showMessage(CharSequence msg, int colorId) {
        TextView textView = (TextView) findViewById(R.id.textView);
        textView.setText(getCurrentTimeString() + " :\n" + msg);
        textView.setTextColor(getResources().getColor(colorId, null));
    }

    private void reset() {
        setAuthenticationState(false);
    }
}
BiometricAuthentication.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.biometricprompt.cipher;

import android.os.CancellationSignal;
import android.os.Handler;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyInfo;
import android.security.keystore.KeyPermanentlyInvalidatedException;
import android.security.keystore.KeyProperties;

import androidx.biometric.BiometricPrompt;
import androidx.fragment.app.FragmentActivity;

import java.io.IOException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.security.spec.InvalidKeySpecException;
import java.util.concurrent.Executor;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;

public class BiometricAuthentication {
    private static final String TAG = "BioAuth";

    private static final String KEY_NAME = "KeyForFingerprintAuthentication";
    private static final String PROVIDER_NAME = "AndroidKeyStore";
    private BiometricPrompt mBiometricPrompt;
    private BiometricPrompt.PromptInfo mPromptInfo;
    private CancellationSignal mCancellationSignal;
    private KeyStore mKeyStore;
    private KeyGenerator mKeyGenerator;
    private Cipher mCipher;


    public BiometricAuthentication(FragmentActivity context, final BiometricPrompt.AuthenticationCallback callback) {
        // 指紋認証の結果を受け取るコールバック
        BiometricPrompt.AuthenticationCallback hook =
                new BiometricPrompt.AuthenticationCallback() {
            @Override
            public void onAuthenticationError(int errorCode, CharSequence errString) {
                android.util.Log.e(TAG, "onAuthenticationError");
                if (callback != null) callback.onAuthenticationError(errorCode, errString);
                reset();
            }

            @Override
            public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
                android.util.Log.e(TAG, "onAuthenticationSuccess");
                if (callback != null) callback.onAuthenticationSucceeded(result);
                reset();
            }

            @Override
            public void onAuthenticationFailed() {
                android.util.Log.e(TAG, "onAuthenticationFailed");
                if (callback != null) callback.onAuthenticationFailed();
            }
        };

        final Handler mHandler = new Handler(context.getMainLooper());
        final Executor mExecutor = new Executor() {
            @Override
            public void execute(Runnable runnable) {
                mHandler.post(runnable);
            }
        };

        mBiometricPrompt = new BiometricPrompt(context, mExecutor, hook);
        final BiometricPrompt.PromptInfo.Builder builder =
            new BiometricPrompt.PromptInfo.Builder()
                .setTitle("Please Authenticate")
                .setNegativeButtonText("Cancel");
        mPromptInfo = builder.build();
        reset();
    }

    public boolean startAuthentication() {
        if (!generateAndStoreKey())
            return false;

        if (!initializeCipherObject())
            return false;

        BiometricPrompt.CryptoObject cryptoObject =
                new BiometricPrompt.CryptoObject(mCipher);

        // 指紋認証を実行
        android.util.Log.e(TAG, "Starting authentication");
        mBiometricPrompt.authenticate(mPromptInfo, cryptoObject);
        return true;
    }

    private void reset() {
        try {
            // ★ポイント2★ "AndroidKeyStore" Providerからインスタンスを取得する
            mKeyStore = KeyStore.getInstance(PROVIDER_NAME);
            mKeyGenerator =
                    KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER_NAME);
            mCipher = Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES
                                         + "/" + KeyProperties.BLOCK_MODE_CBC
                                         + "/" + KeyProperties.ENCRYPTION_PADDING_PKCS7);
        } catch (KeyStoreException | NoSuchPaddingException
                 | NoSuchAlgorithmException | NoSuchProviderException e) {
            throw new RuntimeException("failed to get cipher instances", e);
        }
    }

    private boolean generateAndStoreKey() {
        try {
            mKeyStore.load(null);
            if (mKeyStore.containsAlias(KEY_NAME))
                mKeyStore.deleteEntry(KEY_NAME);
            mKeyGenerator.init(
                 // ★ポイント4★ 鍵生成(登録)時、暗号アルゴリズムは脆弱でないもの(基準を満たすもの)を使用する
                 new KeyGenParameterSpec.Builder(KEY_NAME, KeyProperties.PURPOSE_ENCRYPT)
                 .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
                 .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
                 // ★ポイント5★ 鍵生成(登録)時、ユーザー(指紋)認証の要求を有効にする(認証の有効期限は設定しない)
                 .setUserAuthenticationRequired(true)
                 .build());
            // 鍵を生成し、Keystore(AndroidKeyStore)に保存する
            mKeyGenerator.generateKey();
            return true;
        } catch (IllegalStateException e) {
            android.util.Log.e(TAG, e.getMessage());
            return false;
        } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException
                 | CertificateException | KeyStoreException | IOException e) {
            android.util.Log.e(TAG, "key generation failed: " + e.getMessage());
            // throw new RuntimeException("failed to generate a key", e);
            return false;
        }
    }

    private boolean initializeCipherObject() {
        try {
            mKeyStore.load(null);
            SecretKey key = (SecretKey) mKeyStore.getKey(KEY_NAME, null);
            SecretKeyFactory factory = SecretKeyFactory.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER_NAME);
            KeyInfo info = (KeyInfo) factory.getKeySpec(key, KeyInfo.class);

            mCipher.init(Cipher.ENCRYPT_MODE, key);
            return true;
        } catch (KeyPermanentlyInvalidatedException e) {
            // ★ポイント6★ 鍵を作る時点と鍵を使う時点で指紋の登録状況が変わることを前提に設計を行う
            return false;
        } catch (KeyStoreException | CertificateException | UnrecoverableKeyException
                 | IOException | NoSuchAlgorithmException | InvalidKeySpecException
                 | NoSuchProviderException | InvalidKeyException e) {
            android.util.Log.e(TAG, "failed to init Cipher: " + e.getMessage());
            throw new RuntimeException("failed to init Cipher", e);
        }
    }
}
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.biometricprompt.cipher">
  
  <!-- ★ポイント1★ USE_FINGERPRINT(Android 6.0~Android 8.1)または USE_BIOMETRIC(Android 9.0~)パーミッションを利用宣言する -->
  <uses-permission android:name="android.permission.USE_BIOMETRIC" />
  
  <application
      android:allowBackup="true"
      android:icon="@mipmap/ic_launcher"
      android:label="@string/app_name"
      android:roundIcon="@mipmap/ic_launcher_round"
      android:supportsRtl="true"
      android:theme="@style/Theme.AppCompat.Light">
    <activity android:name="org.jssec.android.biometricprompt.cipher.MainActivity">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
  </application>
  
</manifest>

5.7.1.2. 利用者の認証のみ行う

以下に、利用者による認証のみ行う場合に生体認証機能を利用するためのサンプルコードを示す。この場合、特にセキュリティ上注意すべき点はないが、参考のためサンプルコードを以下に掲載する。

MainActivity.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.biometricprompt.nocipher;

import android.hardware.biometrics.BiometricPrompt;
import android.icu.text.SimpleDateFormat;
import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import org.jssec.android.biometric.authentication.nocipher.R;
import java.util.Date;


public class MainActivity extends AppCompatActivity {
    private BiometricAuthentication mBiometricAuthentication;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mBiometricAuthentication = new BiometricAuthentication(this);

        Button button_biometric_auth = findViewById(R.id.button_biometric_auth);
        button_biometric_auth.setOnClickListener(new View.OnClickListener () {
            @Override
            public void onClick(View v) {
                if (!mBiometricAuthentication.isAuthenticating()) {
                    authenticateByBiometric();
                }
            }
        });
    }

    private boolean authenticateByBiometric () {

        BiometricPrompt.AuthenticationCallback callback =
                new BiometricPrompt.AuthenticationCallback() {
            @Override
            public void onAuthenticationError(int errorCode, CharSequence errString) {
                showMessage(errString, R.color.colorError);
            }

            @Override
            public void onAuthenticationHelp(int helpCode, CharSequence helpString) {
                showMessage(helpString, R.color.colorHelp);
            }

            @Override
            public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
                showMessage(getString(R.string.biometric_auth_succeeded),
                            R.color.colorAuthenticated);
            }

            @Override
            public void onAuthenticationFailed() {
                showMessage(getString(R.string.biometric_auth_failed), R.color.colorError);
            }

        };
        if (mBiometricAuthentication.startAuthentication(callback)) {
            showMessage(getString(R.string.biometric_processing),
                        R.color.colorNormal);
            return true;
        }
        return false;
    }

    private String getCurrentTimeString() {
        long currentTimeMillis = System.currentTimeMillis();
        Date date = new Date(currentTimeMillis);
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("HH:mm:ss.SSS");

        return simpleDateFormat.format(date);
    }

    private void showMessage(CharSequence msg, int colorId) {
        TextView textView = (TextView) findViewById(R.id.textView);
        textView.setText(getCurrentTimeString() + " :\n" + msg);
        textView.setTextColor(getResources().getColor(colorId, null));
    }
}
BiometricAuthentication.java
/*
 * Copyright (C) 2012-2019 Japan Smartphone Security Association
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jssec.android.biometricprompt.nocipher;

import android.content.Context;
import android.content.DialogInterface;
import android.hardware.biometrics.BiometricPrompt;
import android.os.CancellationSignal;

public class BiometricAuthentication {
    private static final String TAG = "BioAuth";

    private BiometricPrompt mBiometricPrompt;
    private CancellationSignal mCancellationSignal;
    private Context mContext;

    // ★ ユーザーが「キャンセル」ボタンをタッチした際の処理
    private DialogInterface.OnClickListener cancelListener =
        new DialogInterface.OnClickListener () {
        @Override
        public void onClick(DialogInterface dialog, int which) {
            android.util.Log.d(TAG, "cancel");
            if (mCancellationSignal != null) {
                if (!mCancellationSignal.isCanceled())
                    mCancellationSignal.cancel();
            }
        }
    };

    public BiometricAuthentication(Context context) {
        mContext = context;
        BiometricPrompt.Builder builder = new BiometricPrompt.Builder(context);
        // 認証プロンプトではキャンセル用のボタンも表示される
        // キャンセル処理は setNegativeButton の第3引数に与える
        // DialogInterface.OnClickListener で実施する
        mBiometricPrompt = builder
            .setTitle("Please Authenticate")
            .setNegativeButton("Cancel",context.getMainExecutor() ,cancelListener)
            .build();
        reset();
    }

    public boolean startAuthentication(
            final BiometricPrompt.AuthenticationCallback callback) {

        mCancellationSignal = new CancellationSignal();

        // 指紋認証の結果を受け取るコールバック
        BiometricPrompt.AuthenticationCallback hook =
                new BiometricPrompt.AuthenticationCallback() {
            @Override
            public void onAuthenticationError(int errorCode, CharSequence errString) {
                android.util.Log.d(TAG, "onAuthenticationError");
                if (callback != null) {
                    callback.onAuthenticationError(errorCode, errString);
                }
                reset();
            }

            @Override
            public void onAuthenticationHelp(int helpCode, CharSequence helpString) {
                android.util.Log.d(TAG, "onAuthenticationHelp");
                if (callback != null) {
                    callback.onAuthenticationHelp(helpCode, helpString);
                }
            }

            @Override
            public void onAuthenticationSucceeded(
                    BiometricPrompt.AuthenticationResult result) {
                android.util.Log.d(TAG, "onAuthenticationSuccess");
                if (callback != null) {
                    callback.onAuthenticationSucceeded(result);
                }
                reset();
            }

            @Override
            public void onAuthenticationFailed() {
                android.util.Log.d(TAG, "onAuthenticationFailed");
                if (callback != null) {
                    callback.onAuthenticationFailed();
                }
            }
        };

        // 指紋認証を実行
        android.util.Log.d(TAG, "Starting authentication");
        mBiometricPrompt.authenticate(mCancellationSignal,
                                      mContext.getMainExecutor(),
                                      hook);

        return true;
    }

    public boolean isAuthenticating() {
        return mCancellationSignal != null && !mCancellationSignal.isCanceled();
    }

    private void reset() {
        mCancellationSignal = null;
    }
}
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.biometric.authentication.nocipher">
  
  <!-- ★ポイント1★ USE_FINGERPRINT(Android 6.0~Android 8.1)または USE_BIOMETRIC(Android 9.0~)パーミッションを利用宣言する -->
  <uses-permission android:name="android.permission.USE_BIOMETRIC" />
  
  <application
      android:allowBackup="true"
      android:icon="@mipmap/ic_launcher"
      android:label="@string/app_name"
      android:roundIcon="@mipmap/ic_launcher_round"
      android:supportsRtl="true"
      android:theme="@style/AppTheme">
    <activity android:name="org.jssec.android.biometricprompt.nocipher.MainActivity">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
  </application>
  
</manifest>

5.7.2. ルールブック

暗号鍵と紐づいた認証を行う際に生体機能を利用する場合には以下のルールを守ること。それ以外の用途で生体認証機能を利用する場合は、特にルールはない。

  1. 鍵生成(登録)時、暗号アルゴリズムは脆弱でないもの(基準を満たすもの)を使用する (必須)
  2. 暗号化するデータは、生体認証以外の手段で復元(代替)可能なものに限る (必須)
  3. 鍵を生成するためには生体情報の登録が必要であり、ユーザーにその旨を伝える (推奨)

5.7.2.1. 鍵生成(登録)時、暗号アルゴリズムは脆弱でないもの(基準を満たすもの)を使用する (必須)

5.6. 暗号技術を利用する」で解説したパスワード鍵や公開鍵等と同様に、生体認証機能を用いて鍵を生成する場合においても、第三者による盗聴を防ぐため一定の基準を満たした脆弱でない暗号アルゴリズムを設定すること。また、暗号アルゴリズムだけではなく、暗号モードやパディングについても脆弱でない安全な方式を選択すること。

暗号アルゴリズムの選択については「5.6.2.2. 脆弱でないアルゴリズム(基準を満たすもの)を使用する (必須)」を参照すること。

5.7.2.2. 暗号化するデータは、生体認証以外の手段で復元(代替)可能なものに限る (必須)

アプリ内データの暗号化に際して生体認証機能を利用する場合は、そのデータが生体認証機能以外の手段でも復元(代替)できるようにアプリを設計すること。一般に、生体情報には秘匿や変更の困難性、誤認識といった問題が付随しているため、認証を生体情報のみに頼ることは避けるべきである。

例えば、生体認証機能を利用して生成した鍵を用いてアプリ内のデータを暗号化した後、端末に登録されていた生体情報がユーザーによって削除されると、データの暗号化に用いていた鍵が使用できなくなり、データを復号することもできなくなる。データの復元が生体認証機能以外の手段でも行えなければ、可用性が損なわれる可能性が生じるのである。 また、生体認証機能を利用して生成した鍵が使用できなくなる状況は生体情報の削除以外でも生じうる。例えば本来正しく使用できるべき鍵が指紋センサーの誤認識あるいはセンサーの不具合によって使用できなくなる可能性もある。

5.7.2.3. 鍵を生成するためには生体情報の登録が必要であり、ユーザーにその旨を伝える (推奨)

生体認証機能を利用して鍵を生成するためには端末にユーザーの生体情報が登録されている必要がある。生体情報の登録をユーザーに促すために設定メニューへ誘導する際には、生体情報が重要な個人情報であることに留意し、ユーザーに対してアプリが生体情報をを利用する必要性や利便性について説明することが望ましい。

指紋認証の登録が必要である旨、ユーザーに伝える

       if (!mFingerprintAuthentication.isFingerprintAuthAvailable()) {
            // ★ポイント★ 鍵を生成するためには生体情報の登録が必要であり、ユーザーにその旨を伝える
            new AlertDialog.Builder(this)
                .setTitle(R.string.app_name)
                .setMessage("生体情報が登録されていません。\n" +
                            "設定メニューの「セキュリティ」で指紋などの生体を登録してください。\n" +
                            "生体情報を登録することにより、簡単に認証することができます。")
                .setPositiveButton("OK", null)
                .show();
            return false;
        }

5.7.3. アドバンスト

5.7.3.1. Androidアプリにおける生体認証機能利用の前提条件

アプリで生体認証機能を利用するためには以下の2点が必要である。

  • 端末にユーザーの生体情報が登録されていること
  • 登録された生体情報に(アプリ固有の)鍵が紐づいていること
ユーザーの生体情報登録

ユーザーの生体情報は設定メニューの「セキュリティ」からしか登録することができず、一般のアプリが生体情報の登録処理を行うことはできない。そのため、アプリが生体認証機能を利用する時点で端末に生体情報が登録されていなければ、ユーザーを設定メニューに誘導し生体情報の登録を促す必要がある。その際、アプリが生体情報を利用する必要性や利便性についての説明がユーザーに対して行われることが望ましい。

なお、生体認証が可能となる前提として、端末に予備の画面ロック方式が設定されていることが必要である。端末に生体情報が登録されている状態で画面ロックを無効にすると、登録済みの生体情報も削除される。

鍵の生成・登録

端末に登録された生体情報と鍵を紐づけるためには、"AndroidKeyStore" Providerが提供するKeyGeneratorやKeyStoreインスタンス等を使用する際に、ユーザー(生体)認証の要求を有効にして、新しい鍵の生成と登録、あるいは既存の鍵の登録を行う。

生体情報と紐づいた鍵を生成する場合は、KeyGeneratorを生成する際のパラメータとして、ユーザー認証の要求を有効にするよう設定する。

生体情報と紐づいた鍵の生成と登録

    try {
        // "AndroidKeyStore" ProviderからKeyGeneratorインスタンスを取得する
        KeyGenerator keyGenerator =
             KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
        keyGenerator.init(
            new KeyGenParameterSpec.Builder(KEY_NAME, KeyProperties.PURPOSE_ENCRYPT)
            .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
            .setUserAuthenticationRequired(true)  // ユーザー(生体)認証の要求を有効にする
            .build());
        keyGenerator.generateKey();
    } catch (IllegalStateException e) {
        // 端末に生体情報が登録されていない
      throw new RuntimeException("No fingerprint registered", e);
    } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException
                | CertificateException | KeyStoreException | IOException e) {
        // 鍵の生成に失敗
        throw new RuntimeException("Failed to generate a key", e);
    }

既存の鍵に生体情報を紐づける場合は、ユーザー認証の要求を有効にする設定を加えたKeyStoreエントリーに鍵を登録する。

既存の鍵に生体情報を紐づける

    SecretKey key = existingKey;  // 既存の鍵

    KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
    keyStore.load(null);
    keyStore.setEntry(
        "alias_for_the_key",
        new KeyStore.SecretKeyEntry(key),
        new KeyProtection.Builder(KeyProperties.PURPOSE_ENCRYPT)
                .setUserAuthenticationRequired(true)  // ユーザー(生体)認証の要求を有効にする
                .build());