5. セキュリティ機能の使い方
暗号や電子署名、Permissionなど、Androidにはさまざまなセキュリティ機能が用意されている。これらのセキュリティ機能は取り扱いを間違えるとセキュリティ機能が十分に発揮されず抜け道ができてしまう。この章では開発者がセキュリティ機能を活用するシーンを想定した記事を扱う。
5.1. 認証(パスワード・パスキー)画面を作る
従来のAndroidアプリ開発では、パスワード入力画面のUIを独自に実装し、入力された情報を自前で管理することが一般的であった。しかし現在では、セキュリティと利便性の両立を図るため、パスワードだけでなくパスキーやGoogleによるログインなどを単一のAPIで安全に扱うCredential Manager(認証情報マネージャー)の利用など、OS標準の認証フレームワークへ委ねるモダンな認証アプローチが推奨されている。
本節では、レガシーなパスワード入力画面を実装する際に守るべき基本的な表示ルールに加え、機密情報の画面保護(スクリーンキャプチャの無効化)、および認証情報を扱う際のセキュアな設計方針について解説する。
5.1.1. サンプルコード
パスワードなどの認証情報を扱う画面を作る際、セキュリティ上考慮すべきポイントについて述べる。
ポイント:
パスワードを入力するときにはマスク表示(●で表示する)機能を用意する
パスワード等の機密画面では FLAG_SECURE を用いてスクリーンキャプチャを無効化する
アプリ内にパスワードや暗号シークレットをハードコーディングしない
Credential Manager等の標準APIを利用し、安全に認証情報を扱う
以下のレイアウトXMLでは、パスワード入力欄にマスク表示を設定している。
activity_password.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="32dp"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ログイン認証"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="32dp" />
<!-- ★ポイント1★ android:inputType="textPassword" を設定することでマスク表示(●で表示する)機能を用意している -->
<EditText
android:id="@+id/passwordEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="パスワード(またはPIN)"
android:inputType="textPassword"
android:layout_marginBottom="24dp" />
<Button
android:id="@+id/loginButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="認証する" />
</LinearLayout>
以下のActivityでは、画面の保護設定と、モダンな認証APIを呼び出す際の基本的な考え方を示している。
PasswordActivity.kt
/*
* Copyright (C) 2012-2026 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.os.Bundle
import android.view.WindowManager
import android.widget.Button
import android.widget.EditText
import androidx.appcompat.app.AppCompatActivity
class PasswordActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ★ポイント2★ パスワード等の機密画面では FLAG_SECURE を用いてスクリーンキャプチャを無効化する
// ※Google Playポリシーで強く要求されている必須のセキュリティ要件です。
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
// レイアウトファイルは activity_password.xml を読み込みます
setContentView(R.layout.activity_password)
// ★ポイント1★ XML側で android:inputType="textPassword" を設定することでマスク表示(●で表示する)機能を用意している
val passwordEditText = findViewById<EditText>(R.id.passwordEditText)
val loginButton = findViewById<Button>(R.id.loginButton)
loginButton.setOnClickListener {
// ★ポイント3★ アプリ内にパスワードや暗号シークレットをハードコーディングしない
// 認証情報はユーザー入力や安全なKeyStoreから取得し、ソースコード内に固定文字列として埋め込まないこと。
// ★ポイント4★ Credential Manager等の標準APIを利用し、安全に認証情報を扱う
// 独自の複雑な認証ロジックを自前で実装する代わりに、OS標準のCredential Managerなどを利用して
// パスキーやパスワードの保存・取得フローを実行する。
performAuthentication()
}
}
private fun performAuthentication() {
// ここにCredential Manager(GetCredentialRequestなど)を呼び出す処理を実装します
}
}
5.1.2. ルールブック
認証画面を作る際、および認証情報を扱う際には以下のルールを守ること。
5.1.2.1. パスワードを入力するときにはマスク表示(●で表示する)機能を用意する (必須)
スマートフォンは人混みで利用されることが多く、第三者にパスワードを盗み見られるリスクが大きい。アプリの仕様として、パスワードをマスク表示する機能が必要である。 レイアウトXMLで指定する場合は、 android:inputType 属性に "textPassword" を指定する。動的にプログラム上で切り替える場合は、EditTextクラスの setInputType() メソッドで InputType.TYPE_TEXT_VARIATION_PASSWORD を追加する。
5.1.2.2. パスワード等の機密画面では FLAG_SECURE を用いてスクリーンキャプチャを無効化する(必須)
パスワード入力画面ではパスワードなどの機密情報が画面上に表示される。そのような画面では、マルウェアによる画面録画や、第三者によるスクリーンキャプチャによって個人情報が流出してしまう恐れがあるため、スクリーンキャプチャを無効にしておく必要がある。
スクリーンキャプチャの無効化は、 WindowManager に addFlags(WindowManager.LayoutParams.FLAG_SECURE) を設定することで実現できる。Google Playのデベロッパープログラムポリシー(Device and Network Abuse)においても、セキュリティおよびプライバシー保護のため、他のアプリの FLAG_SECURE 設定を回避・迂回する手段の作成や促進を行うことは明確に禁止されている。機密画面での FLAG_SECURE の利用は、アプリとユーザーを守るための必須のセキュリティ要件である。
5.1.2.3. アプリ内にパスワードや暗号シークレットをハードコーディングしない(必須)
パスワード、APIキー、暗号化鍵などのクレデンシャルをアプリのソースコードやアセットファイルにハードコーディングしてはならない。ハードコードされたシークレットは、リバースエンジニアリングツールを使うことで攻撃者に簡単に取得されてしまい、重大なセキュリティリスクを引き起こす。 システムレベルの認証情報が必要な場合は KeyChain APIを使用し、各アプリ専用の認証情報は Android KeyStore プロバイダを利用して保存することが必須である。
5.1.2.4. Credential Manager等の標準APIを利用し、安全に認証情報を扱う(推奨)
パスワードを自前で保存したり、ダミー表示のロジックを複雑に作り込んだりするのではなく、パスキーや生体認証と連携できるCredential Manager等のOS標準の認証フレームワークを積極的に利用すべきである。標準APIに処理を委ねることで、セキュアな設計を保ったままユーザーの利便性を向上させることができる。
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以降では、「パスワードを表示」はチェックボックスからトグルボタンに変更されている。
設定 ― パスワードを表示
「パスワードを表示」設定をオンにすると最後に入力した1文字が平文表示となる。一定時間(2秒程度)経過後、または次の文字が入力されると平文表示されていた文字はマスク表示される。オフにすると、入力直後からマスク表示となる。これはシステム全体に影響する設定であり、EditTextのパスワード表示機能を使用しているすべてのアプリに適用される。
パスワード入力時の文字の表示
5.1.3.4. 画面録画の検出
Android 15から、画面録画をアプリで検出する機能が追加された。
画面録画の実行中、アプリが可視または不可視に遷移される際にコールバックが呼び出される。登録されたプロセスのUIDが所有するアクティビティが録画されている場合、アプリは可視であるとみなされる。アプリ上で機密性の高い操作を実行している際に、画面録画されていることをユーザに通知することが可能になった。
例えば、ユーザの操作や他のアプリによって画面の録画が行われている場合に、アプリ上でIDやパスワードを入力する画面や個人情報が表示される画面などが表示される前に、ユーザに対して警告を表示したりアプリの操作を禁止するなど意図しない機密情報・個人情報の録画や配信を防ぐことができる。
実装例は以下の通り。
AndroidManifest.xml
<uses-permission android:name="android.permission.DETECT_SCREEN_RECORDING" />
MainActivity.kt
private val mCallback = Consumer<Int> { state ->
if (state == SCREEN_RECORDING_STATE_VISIBLE) {
Log.i("TAG", "We're being recorded")
} else {
Log.i("TAG", "We're not being recorded")
}
}
override fun onStart() {
super.onStart()
val initialState =
windowManager.addScreenRecordingCallback(mainExecutor, mCallback)
mCallback.accept(initialState)
}
override fun onStop() {
super.onStop()
windowManager.removeScreenRecordingCallback(mCallback)
}
5.1.3.5. 認証情報マネージャーを自動入力と統合する
Android 15(および androidx.credentials ライブラリ)を利用することで、開発者は特定のView(例:ユーザー名やパスワードの入力フィールド)を認証情報マネージャー(Credential Manager)のリクエストに直接リンクさせることができる。ユーザーがこれらのViewにフォーカスすると、対応するリクエストが認証情報マネージャーに送信され、生成された認証情報はプロバイダ間で集約され、自動入力UI(キーボードのインライン候補やプルダウン候補など)に直接表示されるようになる。この機能は、ユーザーが認証情報マネージャーのアカウント選択ツールを誤って閉じた場合の代替手段としても利用可能である。
推奨エンドポイントとしてJetpack androidx.credentialsライブラリが以下の理由により推奨される:
統合性:androidx.credentialsはAndroid Jetpackの一部であり、他のJetpackライブラリとシームレスに統合可能。
保守性:Googleによって公式にサポートされ、定期的なアップデートやバグ修正が提供されるため、信頼性が高い。
セキュリティ:認証情報の管理に関する最新のセキュリティベストプラクティスが実装されており、安全な認証情報管理が可能。
簡便性:開発者が簡単に実装できるように設計されており、コードの複雑さを軽減。
実装手順
GetCredentialRequestの作成
val getPasswordOption = GetPasswordOption()
val getPublicKeyCredentialOption = GetPublicKeyCredentialOption(
requestJson = requestJson
)
val getCredRequest = GetCredentialRequest(
listOf(getPasswordOption, getPublicKeyCredentialOption)
)
getCredential APIの呼び出し
coroutineScope.launch {
try {
val result = credentialManager.getCredential(
context = activityContext, // Use an activity-based context
request = getCredRequest
)
handleSignIn(result)
} catch (GetCredentialException e) {
handleFailure(e)
}
}
ビューに自動入力機能を設定
import androidx.credentials.PendingGetCredentialRequest
usernameEditText.pendingGetCredentialRequest = PendingGetCredentialRequest(
getCredRequest) { response -> handleSignIn(response)
}
passwordEditText.pendingGetCredentialRequest = PendingGetCredentialRequest(
getCredRequest) { response -> handleSignIn(response)
}
注意事項
setPendingGetCredentialRequestはandroidx.credentialsライブラリの拡張APIであり、KotlinとJavaで呼び出し方法が異なる。
現在、この機能はViewオブジェクトでのみ利用可能である。
5.1.3.6. 物理デバイス使用時のパスワード非表示について
Android 17では、セキュリティ強化の一環として、物理デバイス(USBやBluetooth接続の外付けキーボードなど)からパスワードを入力した際の表示挙動が変更されている。
従来のAndroid(Android 16以前)では、パスワード入力欄に文字を入力すると、ユーザーの入力確認のためにシステムの「パスワードを表示」設定に従い「最後に入力した1文字だけが一瞬表示され、その後すぐに●(マスク)に変わる」仕組みが一般的であった。この挙動は画面上のソフトウェアキーボードだけでなく、物理キーボードでの入力時にも同様に適用されるケースがあり、ディスプレイの大きいタブレット端末等において周囲から覗き見られるリスク(ショルダーサーフィング)が高まっていた。
これに対してAndroid 17では、リスクを低減するために挙動が見直され、物理キーボードからの入力時には、キー入力直後からすべての文字が即座に「●」などで表示され、パスワード文字を一時的にも表示しない(最初から完全にマスクする)ように変更された。
この表示挙動はAndroid 17から導入された ShowSecretsSetting によって管理され、「タッチ入力(ソフトキーボード)」と「物理入力(ハードウェアキーボード)」でそれぞれ独立して表示制御される。開発者はアプリ側で独自のパスワード表示の仕組みを実装するのではなく、OSのセキュリティポリシーや設定API( shouldShowPhysicalInput() など)を尊重した標準の入力コンポーネントを利用することが重要である。
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]。
図 5.2.1 Permission 利用宣言確認画面
この確認画面により、ユーザーはそのアプリがどのような機能や情報にアクセスしようとしているのかを知ることができる。もし、アプリの動作に明らかに不必要な機能や情報にアクセスしようとしている場合は、そのアプリは悪意があるアプリの可能性が高い。ゆえに自分のアプリがマルウェアであると疑われないためにも、利用宣言するPermissionは最小限にしなければならない。
ポイント:
利用するPermissionをAndroidManifest.xmlにuses-permissionで利用宣言する
不必要なPermissionは利用宣言しない
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
>
<!-- ★ポイント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回避の問題にも対処している。
図 5.2.2 独自定義の Signature Permission で自社アプリを連携する
ポイント:Componentを提供するアプリ
独自PermissionをprotectionLevel="signature"で定義する
Componentにはpermission属性で独自Permission名を指定する
ComponentがActivityの場合にはintent-filterを定義しない
ソースコード上で、独自定義Signature Permissionが自社アプリにより定義されていることを確認する
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"
>
<!-- ★ポイント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-2026 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-2026 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-2026 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を提供するアプリと同じ開発者鍵で署名する
図 5.2.3 Component を提供するアプリと同じ開発者鍵で APK を署名する
ポイント:Componentを利用するアプリ
独自定義Signature Permissionは定義しない
uses-permissionにより独自Permissionを利用宣言する
ソースコード上で、独自定義Signature Permissionが自社アプリにより定義されていることを確認する
利用先アプリが自社アプリであることを確認する
利用先ComponentがActivityの場合、明示的Intentを使う
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"
>
<queries>
<package android:name="org.jssec.android.permission.protectedapp" />
</queries>
<!-- ★ポイント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-2026 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-2026 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-2026 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を提供するアプリと同じ開発者鍵で署名する
図 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]。
具体的な変更内容については Android Developers サイト https://developer.android.com/reference/android/content/pm/PackageManager を参照のこと。
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証明書ハッシュ値チェッカーを使えば簡単に証明書ハッシュ値を確認できる。
図 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を必要とする処理を実行することができる。
図 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 モデルの仕様変更について」を参照すること。
ポイント:
アプリで利用するPermissionを利用宣言する
不必要なPermissionは利用宣言しない
Permissionがアプリに付与されているか確認する
Permissionを要求する(ユーザーに許可を求めるダイアログを表示する)
Permissionの利用が許可されていない場合の処理を実装する
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
>
<!-- ★ポイント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-2026 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利用時には以下のルールを守ること。
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)を利用しようとしているのかをユーザーが知ることができる。ユーザーが「インストール」をタップすることで、そのアプリは利用宣言した権限が付与され、インストールされるようになっている。
図 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を利用できてしまうケースは以下のような場合に起きる。
ユーザーがまずAttackerAppをインストールすると、Dangerous Permissionの利用許可を求める画面は表示されずに、そのままインストールが完了してしまう
次にProtectedAppをインストールすると、ここでも特に警告もなくインストールできてしまう
その後、ユーザーが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]。
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にて次を行う。
- 保護したいComponentのあるアプリのAndroidManifest.xmlにて、独自Signature Permissionを定義する(Permissionの定義)例: <permission android:name="xxx" android:protectionLevel="signature" />
- 保護したいComponentのあるAndroidManifest.xmlにて、そのComponentの定義タグのpermission属性で、独自定義Signature Permissionを指定する(Permissionの要求宣言)例: <activity android:permission="xxx" ... >...</activity>
- 保護したいComponentにアクセスする連携アプリのAndroidManifest.xmlにて、uses-permissionタグに独自定義Signature Permissionを指定する(Permissionの利用宣言)例: <uses-permission android:name="xxx" />
いて、ソースコード上にて次を実装する。
- 保護したいComponentでリクエストを処理する前に、独自定義したSignature Permissionが自社アプリにより定義されたものかどうかを確認し、そうでなければリクエストを無視する(Component提供側による保護)
- 保護したいComponentにアクセスする前に、独自定義したSignature Permissionが自社アプリにより定義されたものかどうかを確認し、そうでなければComponentにアクセスしない(Component利用側による保護)
最後にAndroid Studioの署名機能にて次を行う。
- 連携するすべてのアプリの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の基本的な使い方である。
- 保護したいComponentのあるアプリのAndroidManifest.xmlにて、独自Signature Permissionを定義する例: <permission android:name="xxx" android:protectionLevel="signature" />
- 保護したいComponentを持つアプリのAndroidManifest.xmlで、保護したいComponentにandroid:permission属性を指定し、1.で定義したSignature Permissionを要求する例: <activity android:permission="xxx" ... >...</activity>
- 保護したいComponentにアクセスしたい連携アプリのAndroidManifest.xmlにて、独自定義Signature Permissionを利用宣言する例: <uses-permission android:name="xxx" />
- 連携するすべてのアプリのAPKを同じ開発者鍵で署名する
実は、この使い方だけでは、次の条件が成立するとSignature Permission回避の抜け道ができてしまう。
説明のために独自定義のSignature Permissionで保護したアプリをProtectedAppとし、ProtectedAppとは異なる開発者鍵で署名したアプリをAttackerAppとする。 ここでSignature Permission回避の抜け道とは、AttackerAppは署名が一致していないにもかかわらず、ProtectedAppのComponentにアクセス可能になることである。
図 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の証明書と予めソースコードに埋め込んだ開発者の証明書を比較することで改ざんの有無を検出することができる。
以下にサンプルコードを示す。なお、実装例のままではプロのハッカーであれば改ざん検出の無効化が容易である。あくまで簡易な実装例であることを念頭においてアプリへの適用を検討するべきである。
ポイント:
主要な処理を行うまでの間に、アプリの証明書が開発者の証明書であることを確認する
SignatureCheckActivity.java
/*
* Copyright (C) 2012-2026 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-2026 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の再委譲問題が生じる。
図 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を要求する例を示す。
ポイント:
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を定義したアプリにおいて以下のような条件に合致するとインストールに失敗するように仕様変更された。
既に同名のPermissionを定義したアプリがインストールされている
そのインストール済みのアプリと署名が一致しない
この仕様変更により、保護対象の機能 (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以降での変更点および、Android 11以降での「1回だけのアクセス許可」についても記載する。
権限の付与・取り消しのタイミング
ユーザー確認が必要な権限(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)ため、権限がアプリに付与されておらず必要な情報や機能にアクセスすることができない状況においても、アプリが異常な動作を起こすことがないよう適切な処理を実装する必要がある。
Normal PermissionおよびSignature PermissionはAndroid OSにより自動的に付与されるため、ユーザー確認を行う必要はない。
図 5.2.10 アプリの権限設定画面
また、Android11.0より、位置情報、マイク、カメラに関連する一部のパーミッションについて、実行時に権限の付与を行う際に、「今回のみ」という選択肢を選べるようになった(1回だけのアクセス許可)。この許可は、アプリの実行中のみ権限を有効化するためのもので、アプリが終了したり、バックグラウンドへ移動して一定時間後 [6] に権限が取り消される。 対象となるパーミッションは以下の通りである。なお、Android6.0以降の権限付与に対応できていれば、付与に際して特別な対応は不要である。
android.permission.ACCESS_FINE_LOCATION
android.permission.ACCESS_BACKGROUND_LOCATION
android.permission.RECORD_AUDIO
android.permission.CAMERA
Android 11で確認したところ、エミュレータ・実機(pixel3)において、いずれも1分間であった。ただし、端末によって異なる可能性がある。
権限の付与・取り消しの単位
いくつかの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が要求された場合は、ユーザーに確認ダイアログが表示されることなく即時に権限が付与される [7]。
この場合も、アプリによる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)以前の端末をターゲットにしているアプリを初めて実行したときに、正常に動作しない可能性がある旨の警告が表示される。更に、ユーザーにストレージアクセスなどの権限の付与を求めるアプリの場合、この警告の前に権限(許可する/許可しない)選択画面が表示される [8]。
https://developer.android.com/about/versions/10/behavior-changes-all#low-target-sdk-warnings
端末の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.2.3.7. Android 11 以降での使用していないアプリの権限が自動リセットされる機能について
Android 11(API Level 30)において、一定期間使用していないアプリの権限をリセットする機能が追加された。この期間は、targetSDKVersionによって、リセットの初期設定が異なる。本機能は、アプリの権限設定画面中の「アプリが使用されていない場合に権限を削除」によって設定する。
targetSDKVersion=30:初期状態で有効
targetSDKVersion<30:初期状態で無効
対象となるパーミッションは、Protection LevelがDangerous Permissionのものである [9]。ただし、1回だけのアクセス許可の対象となるパーミッションでは、1回だけを選択していた場合は毎回確認する設定のままとなる。
権限を必要とする機能を使う際に、都度パーミッションの確認していれば、エラー自体は発生しないが、例えばバックグラウンドで常に動き続けるアプリは、知らない間に機能が止まっていたという事態になる可能性がある。そのため、そのようなアプリでは、自動リセットを無効化するようにユーザに要求する必要がある。
PackageManager#isAutoRevokeWhitelisted()で自動リセット機能が無効になっているかを確認(無効の場合は、trueが返る)
自動リセット機能が有効になっている場合は、アプリの設定画面を呼び出し、権限の設定画面へ誘導する。
ただし、isAutoRevokeWhitelisted()はAPI Level 30で追加されたAPIであるため、targetSDKVersion<30のアプリでは判定できない。そのため、targetSDKVersion<30のアプリで、自動リセットを無効化しておく必要があるアプリは、ユーザーにより自動リセットの設定が変更された場合に備えて、権限が取り消されていた場合に、再度権限の付与を要求するだけでなく、自動リセットの設定見直しを行う導線を設ける必要がある。
Android 11で確認したところ、ACTIVITY_RECOGNITION(身体活動)については、自動リセットが行われても、権限は付与されたままであった。
5.2.3.8. 実行時の権限の取り消し
Android 13から、アプリはユーザによって付与された実行時の権限へのアクセスを、以下のAPIを利用することで取り消すことができるようになった。
revokeSelfPermissionOnKill()
revokeSelfPermissionsOnKill()
特定の実行時の権限を取り消すには、その権限の名前をrevokeSelfPermissionOnKill()で指定する。複数の実行時の権限をまとめて指定するには、権限の名前をまとめて、revokeSelfPermissionsOnKill()に指定する。権限が付与されていない場合は、何も起きない。
これらAPIの導入は最小限の権限の原則に従ったものであり、アプリで定期的に実行時権限の見直しを行い、使わない権限については取り消しを行うことが望ましい。
サンプルコードは以下の通り。ここではandroid.permission.CAMERAとandroid.permission.CALL_PHONE権限を使用するアプリを例にする。
まずは宣言例。
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.CALL_PHONE" />
続いて、ボタン押下時に2つの権限をリクエストもしくは取り消すコード例を以下に示す。
private final String[] PERMISSIONS = {
Manifest.permission.CAMERA,
Manifest.permission.CALL_PHONE
};
private ActivityResultContracts.RequestMultiplePermissions multiplePermissionsContract;
private ActivityResultLauncher<String[]> multiplePermissionLauncher;
@RequiresApi(api = 33)
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
multiplePermissionsContract = new ActivityResultContracts.RequestMultiplePermissions();
multiplePermissionLauncher = registerForActivityResult(multiplePermissionsContract, isGranted -> {
updateDesign();
});
buttonRequest.setOnClickListener(v -> {
multiplePermissionLauncher.launch(PERMISSIONS);
});
buttonRevoke.setOnClickListener(v -> {
revokeSelfPermissionsOnKill(Arrays.asList(PERMISSIONS));
});
上記の例では2つの権限を同時に取り消しているが、個別に取り消す場合は以下のようにrevokeSelfPermissionOnKillを使用する。
revokeSelfPermissionOnKill(Manifest.permission.CAMERA);
revokeSelfPermissionOnKillまたはrevokeSelfPermissionsOnKillを使用するアプリはCompile SDK 32以下の場合ビルド不可のため、サンプルでは「@RequiresApi(api = 33)」を記述している。
実際に権限が取り消されるタイミングは非同期であり、システムが安全だと判断したタイミングで取り消される。すぐに取り消す場合はユーザーにアプリの再起動を促しSystem.exit()を実行するのが確実である。
5.2.3.9. SMS OTP(ワンタイムパスワード)の保護強化
近年、SIMスワップ(他人の電話番号を乗っ取る攻撃)やフィッシング(ユーザーを騙してOTPやパスワードを入力させる攻撃)、さらにはSMS転送型のマルウェアやAccessibility権限の悪用などによって、SMSで送信されたOTP(ワンタイムパスワード)が不正に取得される被害が問題となっている。この問題を背景に、Android 17ではシステム制御下においてSMS OTPへのアクセスを制限し、マルウェア等による認証情報の窃取を防止するための保護強化が導入された。
具体的には、アプリが READ_SMS や RECEIVE_SMS 権限を持っていても、OTPを含むSMSメッセージの受信後3時間が経過するまで、アプリから該当メッセージにアクセスできないように遅延される仕様となった。この3時間の遅延の間、 SMS_RECEIVED_ACTION ブロードキャストは保留され、SMSプロバイダに対するデータベースクエリからも該当メッセージはフィルタリングされる。デフォルトのSMSアプリなどはこの遅延の対象外となるが、従来のように READ_SMS 権限を用いてSMSの受信箱を直接監視し、プログラム的にOTPを自動取得していたアプリは大きな影響を受ける。
本保護機能の適用範囲は以下の通りである。
すべてのアプリ(対象APIレベル問わず): WebOTP形式のメッセージに対して保護が適用される。ドメイン検証によりアプリが対象受信者と判断されない限り、メッセージへのアクセスが3時間遅延される。
Android 17以上をターゲットとするアプリ: WebOTPやSMS Retriever形式以外の「標準のSMSメッセージ」に対しても保護が拡張され、同様に3時間遅延の対象となる。
推奨される実装の移行
アプリによるSMS全体の読み取りを禁止し、不正なOTP取得を防ぐというOSの方針に従い、OTPの抽出のためにSMSメッセージの読み取りが必要なアプリは、権限不要の「SMS Retriever API」や、ユーザー同意を伴う「SMS User Consent API」といったGoogleが想定する正規APIへの移行が強く推奨される。
項目 |
SMS Retriever API |
SMS User Consent API |
|---|---|---|
ユーザー操作 |
不要(完全自動) |
必須(1タップ) |
権限要件 |
不要 |
不要 |
SMSフォーマット |
メッセージ末尾に11文字のアプリハッシュが必要 |
制限なし(任意のフォーマットで取得可能) |
安定性と制御 |
高(フォーマットを自社で制御できれば安定して受信可能) |
高(外部サービスのSMSでも取得可能) |
自社でSMS送信サーバーを制御でき、メッセージフォーマットにアプリハッシュを含めることができる場合は、完全自動でユーザー体験を損なわない SMS Retriever API が適している。一方で、外部サービスから送信されるSMSなどフォーマットの制御が難しい場合は、ユーザーの同意(1タップ)を得ることでメッセージを1件だけ取得できる SMS User Consent API を利用するべきである。
OSの挙動変更の検証例
Android 17で導入されたこの3時間遅延のブロック挙動は、従来の READ_SMS 権限を用いたSMS読み取り処理を試作することで実際に確認できる。以下の検証コード抜粋は、ブロードキャスト( SMS_RECEIVED_ACTION )の受信と、コンテンツプロバイダ( Telephony.Sms.CONTENT_URI )へのクエリの両方を用いてSMSの読み取りを行おうとする例である。
ターゲットSDKを36(Android 16)に設定した場合は、SMS受信と同時にSMS内のOTPが読み取れるのに対し、ターゲットSDKを37(Android 17)に設定した場合は、SMS内のOTPが即座に読み取れなくなる(3時間遅延される)ことが確認できる。
// 1. ブロードキャスト(SMS_RECEIVED_ACTION)を受信する検証コード(抜粋)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
for (message in messages) {
val body = message.messageBody
// 本文からOTPを抽出する処理
// ※Android 17(ターゲットSDK 37)では、受信後3時間経過するまでここが呼ばれない
}
}
}
// 2. コンテンツプロバイダから読み取る検証コード(抜粋)
val cursor = context.contentResolver.query(
Telephony.Sms.CONTENT_URI,
arrayOf(Telephony.Sms.BODY),
null, null,
Telephony.Sms.DATE + " DESC"
)
cursor?.use {
if (it.moveToFirst()) {
val body = it.getString(0)
// 本文からOTPを抽出する処理
// ※Android 17(ターゲットSDK 37)では、受信後3時間経過するまで該当メッセージが取得できない
}
}
検証コードに関する注意: このコードはOSの挙動変化を確認するための検証用コードである。実際の商用アプリにおいては、前述の通り SMS Retriever API 等の正規APIへ移行する必要がある。また、検証を行う際、機密情報(SMS本文やOTP)をログ出力( Log.d など)しないように 4.8.2.1. 運用ログ情報にセンシティブな情報を含めない(必須) のルールに十分留意すること。
5.3. Account Managerに独自アカウントを追加する
Account Managerはアプリがオンラインサービスへアクセスするために必要となるアカウント情報(アカウント名、パスワード)および認証トークンを一元管理するAndroid OSの仕組みである [10]。ユーザーは事前にアカウント情報をAccount Managerに登録しておき、アプリがオンラインサービスにアクセスしようとしたときにユーザーの許可を得て、Account Managerがアプリに認証トークンを自動提供する仕組みである。パスワードという極めてセンシティブな情報をアプリが扱わなくて済むことがAccount Managerの利点である。
Account Managerはオンラインサービスとの同期の仕組みも提供するが、本節では扱っていない。
Account Managerを使用したアカウント管理機能は 図 5.3.1 のような構成となる。「利用アプリ」は認証トークンの提供を受けてオンラインサービスにアクセスするアプリであり、前述のアプリのことである。一方、「Authenticatorアプリ」はAccount Managerの機能拡張であり、Authenticatorと呼ばれるオブジェクトをAccount Managerに提供することにより、Account Managerがそのオンラインサービスのアカウント情報および認証トークンを一元管理できるようになる。利用アプリとAuthenticatorアプリは別のアプリである必要はなく、一つのアプリとして実装することもできる。
図 5.3.1 Manager を使用したアカウント管理機能の構成
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経由で間接的に呼び出されることに注意してほしい。
ポイント:
Authenticatorを提供するServiceは非公開Serviceとする
ログイン画面ActivityはAuthenticatorアプリで実装する
ログイン画面Activityは公開Activityとして他のアプリからの攻撃アクセスを想定する
KEY_INTENTには、ログイン画面Activityのクラス名を指定した明示的Intentを与える
アカウント情報や認証トークンなどのセンシティブな情報はログ出力しない
Account Managerにパスワードを保存しない
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"
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-2026 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-2026 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-2026 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-2026 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. 独自アカウントを作る」が端末にインストールされているときに、独自アカウントの追加や認証トークンの取得ができる。「アクセスリクエスト」画面は両アプリの署名鍵が異なる場合にだけ表示される。
図 5.3.2 サンプルアプリ AccountManager User の動作画面
ポイント:
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"
>
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<queries>
<package android:name="org.jssec.android.accountmanager.authenticator" />
</queries>
<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-2026 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-2026 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アプリを実装する際には以下のルールを守ること。
利用アプリを実装する際には以下のルールを守ること。
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.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 に示す。
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 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を持っている他アプリから、署名に関係なく読めてしまう。これは、バグではなくフレームワークの仕様である [11]。ただし、この挙動もまた、Authenticator側の実装によって変更することができる。
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 に従いサンプルコードを選択すること。
図 5.4.1 HTTP/HTTPS のサンプルコードを選択するフローチャート
センシティブな情報を送受信する場合はSSL/TLSで通信経路が暗号化されるHTTPS通信を用いる。HTTPS通信を用いたほうが良いセンシティブな情報としては以下のようなものがある。
WebサービスへのログインID、パスワード
認証状態を維持するための情報(セッションID、トークン、Cookieの情報など)
その他Webサービスの特性に応じた重要情報・秘密情報(個人情報やクレジットカード情報など)
ここで対象となっているスマートフォンアプリは、サーバーと通信を行うことで連携し、構築されるシステムの一部を担っている。従って、通信のどの部分をHTTPもしくはHTTPSとするのかについては、システム全体を考慮して適切なセキュア設計・セキュアコーディングを施すこと。HTTPとHTTPSの通信方式の違いは 表 5.4.1 を参考にすること。またサンプルコードの違いについては 表 5.4.2 を参考にすること。
HTTP |
HTTPS |
||
特徴 |
URL |
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通信を利用できる。
以下のサンプルコードは、Webサイト上の画像をHTTP通信によって取得し表示するアプリである。 非同期処理にはKotlin標準のコルーチンを使用し、UIスレッドをブロックせずにワーカースレッドで通信処理を行っている。 Webサーバーとの通信で送受信する情報は画像のURL、画像データだが、どれもセンシティブな情報はないとみなしている。そのため、受信データである画像データは攻撃者が用意した攻撃用のデータである可能性がある。簡単のため、サンプルコードでは受信データが攻撃データであるか否かの判定・対策を施していない。同様の理由により、HTTPコネクションおよび画像データを表示するときに発生する可能性のある例外に対する例外処理を最小限にしている。アプリの仕様に応じて適切に処理を実装する必要があることに注意すること。
また、従来のAndroidでは、アプリ全体でHTTP通信を一律に許可するために AndroidManifest.xml の usesCleartextTraffic 属性を true に設定する手法が用いられていた。 しかし、Android 17以降、usesCleartextTraffic 属性は非推奨となり、実質的に無効化されることとなった。Android 17以降の環境では、この属性が true に設定されていても、ネットワークセキュリティ構成による明示的なドメイン許可がない限り、すべてのHTTP通信がOSレベルでブロックされる。 そのため、今後のHTTP通信の実装では、アプリ一括での通信許可に頼るのではなく、ネットワークセキュリティ構成ファイルを用い、通信が必要な最小限のドメイン(例: www.jssec.org)のみを個別に許可する厳格なアクセス制御が必須となる。
なお、Android 7.0 (API Level 24)以降では、android:usesCleartextTraffic 属性よりもネットワークセキュリティ構成の設定が優先されるため、本稿の対応に移行することが強く推奨される。
ポイント:
送信データにセンシティブな情報を含めない
受信データが攻撃者からの送信データである場合を想定する
usesCleartextTraffic属性によるアプリ一括許可は使用せず、ネットワークセキュリティ構成(NSC)を指定する
AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.jssec.android.https.imagesearch" >
<uses-permission android:name="android.permission.INTERNET" />
<!-- usesCleartextTraffic="true" は非推奨化のため削除し、networkSecurityConfig を指定 -->
<application
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config" > <!-- ★ポイント3★ -->
<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>
network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- デフォルト設定:アプリ全体で非暗号化(HTTP)通信を強制ブロック -->
<base-config cleartextTrafficPermitted="false" />
<!-- 1. HTTP画像検索用(www.jssec.org からのHTTP取得を例外許可) -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">www.jssec.org</domain>
</domain-config>
<!-- 2. HTTPSのCT検証回避テスト用(公開テストドメインを指定) -->
<domain-config>
<domain includeSubdomains="true">no-sct.badssl.com</domain>
<!-- ★ポイント5★ SCT情報が含まれていない証明書による通信を一時的に許容するため、CT検証を無効化 -->
<certificateTransparency enabled="false" />
</domain-config>
</network-security-config>
HttpImageSearch.kt
/*
* Copyright (C) 2012-2026 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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.net.HttpURLConnection
import java.net.MalformedURLException
import java.net.ProtocolException
import java.net.URL
class HttpImageSearch {
// ワーカースレッド(Dispatchers.IO)で通信処理を実行する
suspend fun doInBackground(imageUrl: String): ByteArray? = withContext(Dispatchers.IO) {
var con: HttpURLConnection? = null
var responseArray: ByteArray? = null
try {
// ★ポイント1★ 送信データにセンシティブな情報を含めない
// 画像URLの組み立て時などに秘密情報を含めないように注意する
con = connectUrl(imageUrl) ?: return@withContext null
checkResponse(con)
// ★ポイント2★ 受信データが攻撃者からの送信データである場合を想定する
// サンプルにつき検索結果が攻撃者からのデータ(例: 悪意ある画像デコード脆弱性攻撃)である場合の判定・処理は省略
responseArray = getByteArray(con)
} catch (e: IOException) {
// 例外処理は省略
} finally {
con?.disconnect()
}
return@withContext responseArray
}
private fun connectUrl(strUrl: String): HttpURLConnection? {
var con: HttpURLConnection? = null
try {
val url = URL(strUrl)
con = url.openConnection() as HttpURLConnection
con.requestMethod = "GET"
con.connect()
} catch (e: ProtocolException) {
// 例外処理は省略
} catch (e: MalformedURLException) {
// 例外処理は省略
} catch (e: IOException) {
// 例外処理は省略
}
return con
}
private fun getByteArray(con: HttpURLConnection): ByteArray? {
val buff = ByteArray(1024)
var result: ByteArray? = null
var inputStream: BufferedInputStream? = null
var responseArray: ByteArrayOutputStream? = null
try {
inputStream = BufferedInputStream(con.inputStream)
responseArray = ByteArrayOutputStream()
var length: Int
while (inputStream.read(buff).also { length = it } != -1) {
if (length > 0) {
responseArray.write(buff, 0, length)
}
}
result = responseArray.toByteArray()
} catch (e: IOException) {
e.printStackTrace()
} finally {
try {
inputStream?.close()
} catch (e: IOException) {
// 例外処理は省略
}
try {
responseArray?.close()
} catch (e: IOException) {
// 例外処理は省略
}
}
return result
}
@Throws(IOException::class)
private fun checkResponse(con: HttpURLConnection) {
val statusCode = con.responseCode
if (HttpURLConnection.HTTP_OK != statusCode) {
throw IOException("HttpStatus: $statusCode")
}
}
}
ImageSearchActivity.kt
/*
* Copyright (C) 2012-2026 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.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class ImageSearchActivity : ComponentActivity() {
private lateinit var mQueryBox: EditText
private lateinit var mMsgBox: TextView
private lateinit var mImgBox: ImageView
private var mSearchJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
mQueryBox = findViewById(R.id.querybox)
mMsgBox = findViewById(R.id.msgbox)
mImgBox = findViewById(R.id.imageview)
}
override fun onPause() {
// Activityが破棄される、またはバックグラウンドへ移行する際に非同期処理をキャンセルしておく
mSearchJob?.cancel()
super.onPause()
}
fun onHttpSearchClick(view: View) {
val imageUrl = "http://www.jssec.org/common/images/main_visual_local.jpg"
mMsgBox.text = imageUrl
mImgBox.setImageBitmap(null)
// 直前の非同期処理が終わっていない可能性を考慮しキャンセル
mSearchJob?.cancel()
// lifecycleScope を用いて安全に非同期処理を開始(UIスレッドをブロックしない)
mSearchJob = lifecycleScope.launch {
val search = HttpImageSearch()
val result = search.doInBackground(imageUrl)
// 結果の反映(自動的にUIスレッド上で安全に実行される)
if (result == null) {
mMsgBox.append("\n例外発生\n")
} else {
// サンプルにつき画像デコード・表示の際の例外処理は省略
val bmp: Bitmap? = BitmapFactory.decodeByteArray(result, 0, result.size)
mImgBox.setImageBitmap(bmp)
}
}
}
fun onHttpsSearchClick(view: View) {
val query = mQueryBox.text.toString()
mMsgBox.text = "HTTPS 検索開始: $query"
mImgBox.setImageBitmap(null)
// 前回の検索タスクが動作中であればキャンセルする
mSearchJob?.cancel()
// lifecycleScope を用いて安全に非同期HTTPS通信を開始
mSearchJob = lifecycleScope.launch {
val search = HttpsImageSearch()
val result = search.doInBackground(query)
// 結果をUIに反映
if (result == null) {
mMsgBox.append("\n例外発生(通信失敗)\n")
} else {
// 画像データのデコード例外処理はアプリ要件に応じて適切に行うこと
val bmp: Bitmap? = BitmapFactory.decodeByteArray(result, 0, result.size)
mImgBox.setImageBitmap(bmp)
mMsgBox.text = "HTTPS 検索完了: $query"
}
}
}
}
5.4.1.2. HTTPS通信する
HTTPS通信では送受信するデータが暗号化されるだけでなく、接続先サーバーが本物かどうかの検証も行われる。そのためにHTTPS通信開始時のハンドシェイク処理において、サーバーから送られてくるサーバー証明書に対して、AndroidのHTTPSライブラリ内部で次のような観点で検証が行われる。
第三者認証局により署名されたサーバー証明書であること
サーバー証明書の期限等が有効であること
サーバー証明書のSubjectのCN(Common Name)またはSAN(Subject Alternative Names)のDNS名が接続先サーバーのホスト名と一致していること
これらの検証に失敗するとサーバー証明書検証エラーが発生する。サーバー証明書に不備がある場合、もしくは攻撃者が中間者攻撃をしている場合にこのエラーが発生する。エラーが発生した場合には、アプリの仕様に応じて適切な処理を実行する必要がある点に注意すること。
Android 17以上をターゲットとするアプリでは、HTTPS通信の安全性をさらに高めるため、以下の強固なセキュリティ機能がOSレベルでデフォルト有効化される。
ECH(Encrypted Client Hello)のデフォルト有効化
従来のTLS(HTTPS)通信では、通信中身自体は暗号化されていたものの、ハンドシェイク時に接続先ホスト名を提示する「SNI(Server Name Indication)」が平文で送信されていたため、通信経路上の第三者にアクセス先ドメイン名(=利用しているサービス)を覗き見られるリスクが存在していた。 Android 17以降では、このSNI部分も暗号化する「ECH」がデフォルトで有効化される。これにより、ネットワーク監視者に接続先の特定ドメインを把握されるリスクが極めて低くなり、通信プライバシーが大幅に強化される。 ※ECHを実際に機能させるには、アプリの通信ライブラリおよび、接続先サーバー/CDNがECHプロトコルをサポートしている必要がある。
証明書の透明性(Certificate Transparency: CT)のデフォルト検証
TLS証明書の誤発行や不正発行による中間者攻撃を防ぐため、Android 17以降では公開信頼されたTLS証明書に対するCT検証がデフォルトで自動適用される。これにより、接続先証明書が公開ログ(CTログ)に記録し、ログ登録証明としてSCT(Signed Certificate Timestamp)を付与、クライアント(Androidなど)はこのSCTを検証する。つまり、証明書が公開ログに載っているかをチェックして不正な証明書を検出する仕組みである。 SCTが含まれていない(CT非対応の)証明書を使用しているサーバーに接続しようとすると、ハンドシェイク時に SSLHandshakeException が発生して通信が強制遮断される。 ※本番環境の公的CA(Let's EncryptやDigiCertなど)から取得した証明書は問題なくCTをパスするが、開発環境やステージング環境で自己署名証明書を使用する場合は、CTログに登録されないため接続エラーになる。この場合は、後述の通りネットワークセキュリティ構成を用いて、対象ドメインのみ例外的にCT検証を無効化(enabled="false")する必要がある。
ポイント:
URIはhttps://で始める
送信データにセンシティブな情報を含めてよい
HTTPS接続したサーバーからのデータであっても、受信データの安全性を確認する
SSLExceptionに対してアプリに適した例外処理を行う
開発・テスト環境でSCTのない証明書や自己署名証明書を使う場合は、ネットワークセキュリティ構成でCT検証を無効化(自己署名の場合はさらにプライベートCAの信頼設定を追加)する
network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- デフォルト設定:アプリ全体で非暗号化(HTTP)通信を強制ブロック -->
<base-config cleartextTrafficPermitted="false" />
<!-- 1. HTTP画像検索用(www.jssec.org からのHTTP取得を例外許可) -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">www.jssec.org</domain>
</domain-config>
<!-- 2. HTTPSのCT検証回避テスト用(公開テストドメインを指定) -->
<domain-config>
<domain includeSubdomains="true">no-sct.badssl.com</domain>
<!-- ★ポイント5★ SCT情報が含まれていない証明書による通信を一時的に許容するため、CT検証を無効化 -->
<certificateTransparency enabled="false" />
</domain-config>
</network-security-config>
HttpsImageSearch.kt
/*
* Copyright (C) 2012-2026 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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.net.MalformedURLException
import java.net.ProtocolException
import java.net.URL
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLException
class HttpsImageSearch {
// ワーカースレッド(Dispatchers.IO)で安全にHTTPS通信を実行する
suspend fun doInBackground(query: String): ByteArray? = withContext(Dispatchers.IO) {
var con: HttpsURLConnection? = null
var responseArray: ByteArray? = null
try {
// 1回目の通信: 画像の検索を実行
// ★ポイント1★ URIはhttps://で始める
// ★ポイント2★ 暗号化されるため送信データにパスワードやトークン等のセンシティブ情報を含めてもよい
val searchUrl = "https://www.jssec.org/"
con = connectUrl(searchUrl) ?: return@withContext null
checkResponse(con)
// 検索結果から画像のURLを取得する処理(サンプルにつきモックURLを使用)
val imageUrl = "https://www.jssec.org/common/images/main_visual_local.jpg"
con.disconnect()
// 2回目の通信: 画像データを取得
con = connectUrl(imageUrl) ?: return@withContext null
checkResponse(con)
// ★ポイント3★ HTTPS経由であっても、受信データ(画像)の安全性はデコード時に確認する
responseArray = getByteArray(con)
} catch (e: SSLException) {
// ★ポイント4★ 証明書検証エラー(中間者攻撃や期限切れなど)発生時の例外処理
// 実商用アプリでは、ユーザーへエラーダイアログ等で警告を促す処理などを適切に実装すること
e.printStackTrace()
} catch (e: IOException) {
// その他の通信例外処理
e.printStackTrace()
} finally {
con?.disconnect()
}
return@withContext responseArray
}
private fun connectUrl(strUrl: String): HttpsURLConnection? {
var con: HttpsURLConnection? = null
try {
val url = URL(strUrl)
con = url.openConnection() as HttpsURLConnection
con.requestMethod = "GET"
con.connect()
} catch (e: ProtocolException) {
// 例外処理は省略
} catch (e: MalformedURLException) {
// 例外処理は省略
} catch (e: IOException) {
// 例外処理は省略
}
return con
}
private fun getByteArray(con: HttpsURLConnection): ByteArray? {
val buff = ByteArray(1024)
var result: ByteArray? = null
var inputStream: BufferedInputStream? = null
var responseArray: ByteArrayOutputStream? = null
try {
inputStream = BufferedInputStream(con.inputStream)
responseArray = ByteArrayOutputStream()
var length: Int
while (inputStream.read(buff).also { length = it } != -1) {
if (length > 0) {
responseArray.write(buff, 0, length)
}
}
result = responseArray.toByteArray()
} catch (e: IOException) {
e.printStackTrace()
} finally {
try {
inputStream?.close()
} catch (e: IOException) {
// 例外処理は省略
}
try {
responseArray?.close()
} catch (e: IOException) {
// 例外処理は省略
}
}
return result
}
@Throws(IOException::class)
private fun checkResponse(con: HttpsURLConnection) {
val statusCode = con.responseCode
if (HttpsURLConnection.HTTP_OK != statusCode) {
throw IOException("HttpStatus: $statusCode")
}
}
}
5.4.1.3. プライベート証明書でHTTPS通信する
ここでは第三者認証局から発行されたサーバー証明書ではなく、私的に発行したサーバー証明書(プライベート証明書)でHTTPS通信をするサンプルコードを示す。プライベート認証局のルート証明書とプライベート証明書の作成方法およびWebサーバーのHTTPS設定については「5.4.3.1. プライベート証明書の作成方法とサーバー設定」を参考にすること。またサンプルプログラムのassets中のcacert.crtファイルはプライベート認証局のルート証明書ファイルである。
以下のサンプルコードは、Webサーバー上の画像を取得して表示するアプリである。WebサーバーとはHTTPSを用いた通信を行う。UIスレッドでの通信を避けるために、AsyncTaskを利用して通信処理用のワーカースレッドを作成している。Webサーバーとの通信で送受信する情報は画像のURLと画像データで、このサンプルではどちらもセンシティブな情報とみなしている。また、簡単のため、SSLExceptionに対してはユーザーへの通知などの例外処理を行っていないが、アプリの仕様に応じて適切な処理を実装する必要がある。
ポイント:
プライベート認証局のルート証明書でサーバー証明書を検証する
URIはhttps://で始める
送信データにセンシティブな情報を含めてよい
受信データを接続先サーバーと同じ程度に信用してよい
SSLExceptionに対しユーザーに通知する等の適切な例外処理をする
PrivateCertificateHttpsGet.java
/*
* Copyright (C) 2012-2026 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-2026 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通信する場合には以下のルールを守ること。
HTTP通信を行う場合は、usesCleartextTraffic属性による一括許可は使用せず、ネットワークセキュリティ構成で必要なドメインのみを個別に許可する (必須)
開発・テスト環境でSCT情報がない証明書や自己署名証明書を使う場合は、ネットワークセキュリティ構成でCT検証を安全にオプトアウトする (必須)
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.2.6. HTTP通信を行う場合は、usesCleartextTraffic属性による一括許可は使用せず、ネットワークセキュリティ構成で必要なドメインのみを個別に許可する (必須)
Android 17以降、AndroidManifest.xml の usesCleartextTraffic 属性は非推奨となり、実質的に無効化される。
この属性を true に設定していても、ネットワークセキュリティ構成ファイルによる明示的なドメイン許可がない限り、すべてのHTTP通信がOSレベルでブロックされる。
そのため、HTTP通信が必要な場合は、アプリ一括許可に頼るのではなく、ネットワークセキュリティ構成ファイルを用いて、やむを得ずクリアテキスト通信を行う最小限のドメインのみを個別に許可(cleartextTrafficPermitted="true")しなければならない。
5.4.2.7. 開発・テスト環境でSCT情報がない証明書や自己署名証明書を使う場合は、ネットワークセキュリティ構成でCT検証を安全にオプトアウトする (必須)
Android 17以降をターゲットとするアプリでは、HTTPS通信時に「証明書の透明性(CT)」の自動検証がデフォルトで有効化される。
本番環境の公的CA(Let's Encrypt等)から取得した証明書は問題なくパスするが、検証用に構築したステージングサーバーなど、SCT情報が含まれていない証明書や自己署名証明書を使用しているサーバーに接続しようとすると、SSLHandshakeException が発生して通信が強制遮断される。
このため、開発・テスト用の特定の通信先に限っては、ネットワークセキュリティ構成において対象ドメインを指定し、例外的にCT検証を無効化(<certificateTransparency enabled="false" />)する必要がある。
なお、自己署名証明書を使用する場合は、CT検証の無効化だけでなく、そのプライベートCA証明書自体をアプリに信頼させる設定(<trust-anchors>)を同時に記述しなければならない点に注意すること。
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にルート証明書をインストールすることができる。
図 5.4.2 プライベート認証局のルート証明書のインストール手順
図 5.4.3 ルート証明書がインストールされていることの確認
Android OSにプライベート認証局のルート証明書をインストールすると、その認証局から発行されたプライベート証明書をすべてのアプリで正しく証明書検証できるようになる。下図はChromeブラウザで https://selfsigned.jssec.org/droid_knight.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通信する」で紹介したサンプルコードを参照すること。
アプリ内に保持した証明書・公開鍵を用いてハンドシェイク処理後に再検証する
ハンドシェイク処理が行われた後に接続先を再検証するためには、まずハンドシェイク処理で検証されシステムに信頼された証明書チェーンを取得し、その証明書チェーンを、あらかじめアプリ内に保持しておいた情報と照合する。照合の結果、保持しておいた情報と一致するものが含まれていれば通信を許可し、含まれていなければ通信処理を中断させればよい。 ただし、ハンドシェイク処理でシステムに信頼された証明書チェーンを取得する際に以下のメソッドを使用すると、期待通りの証明書チェーンが得られず、結果としてピンニングによる検証が正常に機能しなくなってしまう危険がある [12]。
この危険性については、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.4. Google Play開発者サービスによるSecurity Providerの脆弱性対策」を参照のこと。
5.4.3.7. Network Security Configuration
Android 7.0(API Level 24)において、ネットワーク通信時のセキュリティ設定をアプリ毎に行うことができるNetwork Security Configurationが導入された。この仕組みを利用することにより、プライベート証明書でのHTTPS通信やピンニングによる証明書検証のほか、非暗号化(HTTP)通信の抑制、デバッグ時のみ有効なプライベート証明書の導入など、アプリのセキュリティを向上させる種々の施策をアプリに簡単に取り入れることができる [13]。
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通信(非暗号化通信)を抑制することができる。
非暗号化通信を抑制する方法は次の通りである:
基本的には、<base-config> タグにより、すべてのドメインとの通信で非暗号化通信(HTTP通信)を抑制する [14]
やむを得えず非暗号化通信が必要なドメインのみ <domain-config> タグにより個別に非暗号化通信を許可する例外設定をおこなう。なお、非暗号化通信を許可して良いかどうかの判断には「5.4.1.1. HTTP通信する」を参考にすること
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と呼ばれる脆弱性 [15] によって、SSL3.0のパディングは安全ではないことが判明した。 1999年にリリースされたTLS1.0(RFC2246)ではパディングの設計上の欠陥は修正されているが、暗号化されたデータの一部を抜き出す攻撃手法BEASTが、DuongとRizzoにより2011年に発表された [16] 。 セキュリティに関する修正(TLS1.0の安全性強化)を行なったTLS1.1(RFC4346)が2006年にリリースされ、更に2008年にリリースされたTLS1.2(RFC5246)では ハッシュ関数SHA-2(SHA-256やSHA-384)の利用、認証付暗号(AEAD)利用モード(GCM, CCM)が利用可能な暗号スイートのサポートなど、より強力な暗号アルゴリズムの利用が可能になっている。
このような背景から米国立標準技術研究所(NIST)は、2018年10月15日発行のTLSに関するガイドライン [17] においてTLS1.1以前を非推奨あるいは禁止し、政府系だけではなく政府系以外のアプリケーションをサポートするサーバもTLS1.2への移行を求めている。それにともない、 ここ数年のセキュリティ騒動や新しいバージョンのTLSの普及から「SSLやTLSの古いバージョン」のサポートを取りやめるサイトやサービスも増えており、TLS1.2への移行が確実に進みつつある [18] 。
移行を要件としている例として、クレジットカードセキュリティの国際標準化団体 PCI SSCによって策定された「ペイメントカード業界データセキュリティ基準(PCI DSS: Payment Card Industry Data Security Standard)」と呼ばれるセキュリティ基準(最新版は2018年5月公開の v3.2.1)が挙げられる [19] 。 現在Eコマースはスマートフォンやタブレットでも広く利用されており、決済にはクレジットカードを利用する事が普通であろう。 本書(Androidアプリのセキュア設計・セキュアコーディングガイド)を利用するユーザーにおいても、アプリケーションを介してクレジットカード情報などをサーバサイドに送信するサービスを企画するケースも多いと予想するが、ネットワークでクレジットカードを利用する際にはその経路の安全性を確保することが必要で、PCI DSSは、このようなサービスにおける会員データを取り扱う際の基準であり、カードの不正利用や情報漏えいなどを防止する目的で定められている。 このセキュリティ基準の中 では具体的なバージョンは明記していないが、SSLの全てのバージョンおよび既知のエクスプロイト(攻撃プログラム)に対して脆弱なTLSの初期バージョンを2018年6月30日までで廃止とし、安全とされるバージョン(すなわちTLS1.2以降)にアップグレードすることを要求している 。
“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)
“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)
“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)
SSL/TLS 暗号設定ガイドライン, IPA (https://www.ipa.go.jp/security/vuln/ssl_crypt_config.html)
“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月現在) [20] によると現在利用されているAndroid OSのシェアは4.4以降が96.2%と圧倒的であるため、扱う資産などの安全性を考慮して、TLS 1.2への移行を真剣に検討されることをお薦めする。
図 5.4.5 Android OS のシェア (Andorid Developpers サイトより)
Distribution dashboard - Platform versions (https://developer.android.com/about/dashboards/index.html)
2018年8月に公開されたTLS1.3(RFC8446)は、TLS1.2策定以降に見つかった新たな脆弱性や攻撃手法への対策および性能向上を狙いとして、プロトコルと暗号アルゴリズムの抜本的な再設計が行われた [21] 。 Android 10ではプラットフォームのTLS実装がTLS 1.3をサポートするようになり、すべてのTLS接続に対してデフォルトでTLS 1.3が有効になった [22] 。 なお、Android 10からは、安全性の低い下記の暗号スイートが削除された(モード:CBC、MAC:SHA2) [23] 。
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
The Transport Layer Security (TLS) Protocol Version 1.3 (https://datatracker.ietf.org/doc/rfc8446/)
Android Q features and APIs - TLS 1.3 support (https://developer.android.com/preview/features#tls-1.3)
SHA-2 CBC cipher suites removed (https://developer.android.com/preview/behavior-changes-all#sha2-cbc-cipher-suites)
5.4.3.9. ECH(Encrypted Client Hello)の有効化
Android 17以上をターゲットとするアプリでは、TLSハンドシェイク時にServer Name Indication(SNI)を暗号化するTLS拡張機能であるECH(Encrypted Client Hello)がデフォルトで有効になる。これまでのHTTPS通信でも通信内容は暗号化されていたが、「どのサイトにアクセスしているか(ドメイン名)」を表すSNIは平文で送信されていたため、ドメイン名から利用しているサービス名などが推測される場合があった。Android 17のECHではこのドメイン名部分も暗号化されるため、ネットワーク監視者がアプリの接続先の特定のドメインを簡単に特定することを防ぎ、ユーザーのプライバシーがさらに強化される。
アプリ内でTLS接続時にECHは自動利用されるが、実際にECHが機能するには以下の条件を満たす必要がある。
ネットワークライブラリがECH対応: HttpEngine、WebView、OkHttpなどがサポートしていること。
サーバーがECH対応: HTTPS DNS(HTTPS RR)にECH設定があり、利用しているCDN(Cloudflareなど)やリモートサーバーがECHプロトコルをサポートしていること。
TLSネゴシエーション成功: 条件が揃えば自動的にECHが使用される。揃わなければ通常のTLSにフォールバックされる(ネゴシエートできない場合、クライアントはランダム化されたコンテンツを含むECH拡張機能を送信するECH GREASEと呼ばれるメカニズムが働く)。
項目 |
従来TLS |
Android 17 (ECH) |
|---|---|---|
通信内容 |
暗号化 |
暗号化 |
ドメイン(SNI) |
平文可視 |
暗号化 |
監視耐性 |
△ |
◎ |
デバッグやネットワーク監視への影響
セキュリティ製品などでURLフィルタリングしている場合やプロキシなどを利用している場合には、SNIが暗号化されることにより影響が出る可能性がある。また、開発環境の観点からも、通信ログ(pcap等)からドメインの特定が不可能になるため、パケットキャプチャを用いたデバッグに影響が出るケースが考えられる。
ネットワークセキュリティ構成ファイルによるカスタマイズ
ECHは自動有効化され、アプリ開発者は基本的には設定不要であるが、デバッグ目的などでこの動作をカスタマイズしたい場合は、ネットワークセキュリティ構成ファイル(Network Security Configuration)を使用する。Android 17では新たに <domainEncryption> 要素が追加されており、 <base-config> または <domain-config> タグ内でグローバルまたはドメインごとにECHモード( "enabled" や "disabled" など)を選択することができる。
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">example.com</domain>
<domainEncryption mode="enabled" />
</domain-config>
</network-security-config>
ECHを確実に動作させるにはアプリ側だけでなくサーバーやDNS側の対応が鍵となるため、ネットワーク全体の構成を確認することが望ましい。
5.4.3.10. 証明書の透明性(CT)への対応
Android 17以降をターゲットとするアプリでは、公開信頼されたTLS証明書に対するCertificate Transparency(CT)の検証がデフォルトで有効となる。TLS証明書(HTTPS用証明書)は、CA(認証局)が発行する仕組みになっているが、過去に誤発行・不正発行が発生した。その結果、MITM攻撃等に晒されるリスクが問題視されており、その対策としてCTが導入された。CTは、発行された証明書を公開ログ(CTログ)に記録し、ログ登録証明としてSCT(Signed Certificate Timestamp)を付与、クライアント(Androidなど)はこのSCTを検証する。つまり、証明書が公開ログに載っているかをチェックして不正な証明書を検出する仕組みである。
Android 16以前ではCTは自分でONにしないと機能が使われないオプトイン形式であったが、Android 17ではデフォルト有効化され暗黙的に適用されるため、開発者が意識しなくても検証が走る。CT非対応証明書の場合、TLS接続エラーやAPI通信失敗、アプリ動作停止の可能性がある。
OSの挙動変更の検証例
実際にCTの検証が機能しているかは、SCTが含まれていないテスト用のサーバ( https://no-sct.badssl.com など)へ接続を試みることで確認できる。Android 17をターゲットとした環境(プロキシ等を介さないピュアなネットワーク環境)では通信がブロックされ、 SSLHandshakeException が発生してSCTの検証に失敗することが確認できる。
// CTの動作を検証するコード(抜粋)
private suspend fun testConnection(urlString: String, siteName: String): String {
return withContext(Dispatchers.IO) {
try {
// "https://no-sct.badssl.com" などのSCTなしサイトへ接続を試みる
val url = URL(urlString)
val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 8000
connection.readTimeout = 8000
connection.connect()
val responseCode = connection.responseCode
connection.disconnect()
"$siteName への接続に成功しました\n(HTTP Status: $responseCode)"
} catch (e: SSLHandshakeException) {
// Android 17(ターゲットSDK 37)では、SCTがないためここでSSLHandshakeExceptionがスローされる
"$siteName への接続に失敗しました\n理由: SSLHandshakeException\n${e.message}"
} catch (e: Exception) {
"$siteName への接続に失敗しました\n理由: ${e.javaClass.simpleName}: ${e.message}"
}
}
}
デバッグやステージング環境への影響
実務的には本番環境の直前に用意される最終確認用のテスト環境であるステージング環境などで、self-signed証明書(自己署名証明書)を利用する場合は注意が必要である。self-signed証明書は信頼されたCAではなく自分自身で発行した証明書のため、CTログに登録されず、Android 17環境では通信エラーが発生する可能性がある。社内プロキシによる証明書差し替えが行われる開発環境でも同様である。
ネットワークセキュリティ構成ファイルによるカスタマイズ
開発者としては利用している証明書がCT対応か確認することが重要となるが、DigiCertやLet's Encrypt等の公的CAは基本的には問題がないと考えられる。ステージング用環境を想定し自己署名証明書を利用する場合には、ネットワークセキュリティ構成ファイル(Network Security Configuration)で対象ドメインのCTを明示的にオフ(false)にするか、正規証明書を利用することが推奨される。
<domain-config>
<domain includeSubdomains="true">staging.example.com</domain>
<certificateTransparency enabled="false"/>
</domain-config>
CT導入のメリットは、不正証明書の発見、MITM攻撃の抑止、CAの透明性向上である。運用上の注意点としては、社内・検証環境が動作しなくなる可能性がある点や証明書管理の厳格化が必要な点である。Android 17では証明書がCTログに載っていることを前提にした世界になるが、全HTTPS通信に関係する機能であり広範囲に影響があるため開発者は注意が必要である。
5.4.3.11. usesCleartextTrafficの非推奨化とHTTP通信の許可
Android 17から、AndroidManifest.xmlでHTTP通信を許可するために用いられてきた usesCleartextTraffic 属性が非推奨(deprecated)となり、将来的な削除に向けて実質無効化への運用が開始された。
従来は usesCleartextTraffic="true" と設定することでアプリ全体のHTTP通信が一律で許可されていた。しかしAndroid 17以降では、このフラグがオンであっても、ネットワークセキュリティ構成ファイル(Network Security Configuration)が存在しない場合はHTTP通信がブロックされる仕様に変更された。
今後HTTP通信を必要とする場合は、フラグ単体での許可に頼るのではなく、ネットワークセキュリティ構成ファイルを用いて、どのドメインとの通信を許可するのかを明示的に設定するセキュリティ設計が求められる。
ネットワークセキュリティ構成ファイルによるHTTP通信の許可
特定のドメイン(例:example.com)に対するHTTP通信を許可する場合、ネットワークセキュリティ構成ファイルを以下のように設定する。これにより、明示的に許可されたドメイン以外のHTTP通信はブロックされる。
<network-security-config>
<base-config cleartextTrafficPermitted="false"/>
<domain-config cleartextTrafficPermitted="true">
<domain>example.com</domain>
</domain-config>
</network-security-config>
OSの挙動変更の検証例
以下の検証コードは、ネットワークセキュリティ構成ファイルでHTTP通信が許可されたドメイン(http://example.com)に対してGETリクエストを行う例である。ネットワークセキュリティ構成ファイルによる許可設定がない場合や、未許可のドメインに対してHTTP通信を行った場合は、Android 17の環境では通信がブロックされ、例外が発生する。
// HTTP通信を行う検証コード(抜粋)
suspend fun fetchData(urlString: String): String = withContext(Dispatchers.IO) {
try {
val url = URL(urlString)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 5000
connection.readTimeout = 5000
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
connection.inputStream.bufferedReader().use { it.readLine() ?: "Empty response" }
} else {
"Error: $responseCode"
}
} catch (e: Exception) {
// ネットワークセキュリティ構成ファイルで許可されていない場合、
// ここで例外(Cleartext communication to [ドメイン名] not permitted by network security policy等)がスローされる
"Exception: ${e.message}"
}
}
これまではフラグ1つで全てのHTTP通信が許可される形であったが、Android 17以降では「どの通信を許可するか」をドメイン単位で明示しないとブロックされるため、開発者はアプリが通信する外部接続先を正確に把握し、必要最小限の許可にとどめる厳格な管理が必要となる。
5.5. プライバシー情報を扱う
近年、プライバシー情報を守るための世界的な潮流として「プライバシー・バイ・デザイン」が提唱されており、この概念に基づき各国政府においてもプライバシー保護のための法制化を進めているところである。
スマートフォン内の利用者情報を活用するアプリは、利用者が個人情報やプライバシーの観点から安全・安心にアプリを活用できるように、利用者情報を適切に取り扱うとともに、利用者に対して分かりやすい説明を行い、利用者に利用の可否の選択を促すことが求められる。そのためには、アプリがどのような情報をどのように扱うか等を示したアプリ毎のアプリケーション・プライバシーポリシー(以下、アプリ・プライバシーポリシー)を作成・提示するとともに、慎重な取り扱いが求められる利用者情報の取得・利用については事前にユーザーの同意を得る必要がある。なお、アプリケーション・プライバシーポリシーは従来から存在する「個人情報保護方針」や「利用規約」等とは異なり別途作成を要するものであることに留意すること。
プライバシーポリシーの作成や運用に関して、詳しくは総務省が提唱する『スマートフォン プライバシー イニシアティブ』、『スマートフォン プライバシー イニシアティブⅡ』及び『スマートフォン プライバシーイニシアティブⅢ』(以下 総務省SPIと省略)を参照のこと。
また、本記事で扱う用語については、本文内の解説および「5.5.3.2. 用語解説」を参照すること。
5.5.1. サンプルコード
アプリ・プライバシーポリシーの作成には、一般に公開されている「アプリケーション・プライバシーポリシー作成支援ツール [24]」を利用することもできる。このツールの出力はHTML形式およびXML形式となっており、概要版アプリケーション・プライバシーポリシーと詳細版アプリケーション・プライバシーポリシーのそれぞれのファイルが作成される。作成されたXMLファイルには検査用のタグがつくなど、総務省SPIに準拠した形となっている。以下のサンプルコードでは、上記ツールを使って作成したHTMLファイルを利用してアプリ・プライバシーポリシーを提示する例を示す。
図 5.5.1 概要版アプリケーション・プライバシーポリシーの例
具体的には、次の判定フローに従うことで利用するサンプルコードを判断できる。
図 5.5.2 プライバシー情報を扱うサンプルコードを選択するフローチャート
ここで、包括同意とは、アプリ初回起動時のアプリ・プライバシーポリシーの提示・確認により、アプリがサーバーに送信する利用者情報について包括的に同意を得ることである。
また、個別同意とは、個々の利用者情報について送信の直前に個別の同意を得ることである。
5.5.1.1. [包括同意・個別同意あり] アプリ・プライバシーポリシーを組み込んだアプリ
ポイント:[包括同意・個別同意あり] アプリ・プライバシーポリシーを組み込んだアプリ
初回起動時(アップデート時)に、アプリが扱う利用者情報の送信について包括同意を得る
ユーザーの包括同意が得られていない場合は、利用者情報の送信はしない
慎重な取り扱いが求められる利用者情報を送信する場合は、個別にユーザーの同意を得る
ユーザーの個別同意が得られていない場合は、該当情報の送信はしない
ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
送信した情報をユーザー操作により削除する手段を用意する
ユーザー操作により利用者情報の送信を停止する手段を用意する
利用者情報の紐づけにはUUID/cookieを利用する
アプリ・プライバシーポリシー概要版をassetsフォルダ内に配置しておく
MainActivity.java
/*
* Copyright (C) 2012-2026 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) {
int itemId = item.getItemId();
if (itemId == R.id.action_show_pp) {
// ★ポイント5★ ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
Intent intent = new Intent();
intent.setClass(this, WebViewAssetsActivity.class);
startActivity(intent);
return true;
} else if (itemId == R.id.action_del_id) {
// ★ポイント6★ 送信した情報をユーザー操作により削除する手段を用意する
new SendDataAsyncTack().execute(DEL_ID_URI, UserId);
return true;
} else if (itemId == 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-2026 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-2026 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. [包括同意あり] アプリ・プライバシーポリシーを組み込んだアプリ
ポイント:[包括同意あり] アプリ・プライバシーポリシーを組み込んだアプリ
初回起動時(アップデート時)に、アプリが扱う利用者情報の送信について包括同意を得る
ユーザーの包括同意が得られていない場合は、利用者情報の送信はしない
ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
送信した情報をユーザー操作により削除する手段を用意する
ユーザー操作により利用者情報の送信を停止する手段を用意する
利用者情報の紐づけにはUUID/cookieを利用する
アプリ・プライバシーポリシー概要版をassetsフォルダ内に配置しておく
MainActivity.java
/*
* Copyright (C) 2012-2026 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) {
int itemId = item.getItemId();
if (itemId == R.id.action_show_pp) {
// ★ポイント3★ ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
Intent intent = new Intent();
intent.setClass(this, WebViewAssetsActivity.class);
startActivity(intent);
return true;
} else if (itemId == R.id.action_del_id) {
// ★ポイント4★ 送信した情報をユーザー操作により削除する手段を用意する
new SendDataAsyncTack().execute(DEL_ID_URI, UserId);
return true;
} else if (itemId == 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-2026 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-2026 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. [包括同意なし] アプリ・プライバシーポリシーを組み込んだアプリ
ポイント:[包括同意なし] アプリ・プライバシーポリシーを組み込んだアプリ
ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
送信した情報をユーザー操作により削除する手段を用意する
ユーザー操作により利用者情報の送信を停止する手段を用意する
利用者情報の紐づけにはUUID/cookieを利用する
アプリ・プライバシーポリシー概要版をassetsフォルダ内に配置しておく
MainActivity.java
/*
* Copyright (C) 2012-2026 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) {
int itemId = item.getItemId();
if (itemId == R.id.action_show_pp) {
// ★ポイント1★ ユーザーがアプリ・プライバシーポリシーを確認できる手段を用意する
Intent intent = new Intent();
intent.setClass(this, WebViewAssetsActivity.class);
startActivity(intent);
return true;
} else if (itemId == R.id.action_del_id) {
// ★ポイント2★ 送信した情報をユーザー操作により削除する手段を用意する
new sendDataAsyncTack().execute(DEL_ID_URI, UserId);
return true;
} else if (itemId == 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-2026 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. アプリ・プライバシーポリシーを組み込まないアプリ
ポイント:アプリ・プライバシーポリシーを組み込まないアプリ
取得した情報を端末内部でのみ利用する場合、アプリ・プライバシーポリシーを表示しなくても良い
マーケットプレイス等のアプリ説明欄に、取得した情報を外部送信しない旨を記載する
MainActivity.java
/*
* Copyright (C) 2012-2026 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★マーケットプレイス等のアプリ説明欄に、取得した情報を外部送信しない旨を記載する
図 5.5.3 マーケットプレイス上での説明例
5.5.2. ルールブック
プライバシー情報を扱う際には以下のルールを守ること。
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");
}
図 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");
}
図 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;
図 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. 利用者情報を端末内のみで利用する場合、外部送信しない旨をユーザーに通知する (推奨)
利用者の端末内部で一時的に利用者情報にアクセスするのみの場合であっても、利用者の理解を助け透明性を高めるために、その旨を伝えることが望ましい。具体的には、アクセスした利用者情報は、ある決まった目的のために端末内部で一時的に使用するのみであり、端末内に保存したり、外部サーバに送信したりしない旨をを利用者に通知すると良い。通知方法としては、マーケットプレイス上でのアプリ説明欄に記載するなどの方法が考えられる。なお、端末内での一時利用のみの場合は、アプリ・プライバシーポリシーへの記載は必須ではない。
図 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) に記載されている用語解説を引用しておく。
用語 |
解説 |
事業者プライバシーポリシー |
事業者における個人情報保護方針を記したプライバシーポリシー。 個人情報保護法に基づき作成。 |
アプリ・プライバシーポリシー |
アプリケーション向けプライバシーポリシー。総務省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クラス
API |
取得情報 |
targetSDKVersion=29 |
targetSDKVersion<29 |
(フィールド)BUILD.SERIAL |
デバイスのシリアル番号 |
Unknown |
Unknown |
getSerial |
デバイスのシリアル番号 |
SecurityException |
Unknown |
TelephonyManagerクラス
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.5.3.5. データアクセスの監査について
Android 11 では位置情報や連絡先リストなどユーザーのプライベートデータにアクセスするプロセスに透明性をもたせるため、「データアクセスの監査」が導入されている。利用するにはAppOpsManager.OnOpNotedCallbackのインスタンスを登録し、アクティビティの onCreate() メソッド内など、データアクセスを監査するコンポーネントにコールバックロジックを実装する。これにより、ユーザーのプライベートデータへのアクセス記録が容易となる。
アクセス記録には定義したアトリビューションタグを含めることもでき、定義したアトリビューションタグの名前がアプリのログに含まれるようコールバックロジックを定義することもできる。
public class SharePhotoLocationActivity extends AppCompatActivity {
private Context attributionContext;
// 〜省略〜
@Override
public void onCreate(@Nullable Bundle savedInstanceState,
@Nullable PersistableBundle persistentState) {
attributionContext = createAttributionContext("sharePhotos");
// 〜省略〜
AppOpsManager.OnOpNotedCallback appOpsCallback =
new AppOpsManager.OnOpNotedCallback() {
private void logPrivateDataAccess(String opCode, String attributionTag, String trace) {
Log.i(TAG, "Private data accessed. ");
Log.i(TAG,"Operation:" + opCode);
Log.i(TAG,"Attribution Tag: " + attributionTag);
Log.i(TAG, "Stack Trace: " + trace);
}
@Override
public void onNoted(@NonNull SyncNotedAppOp syncNotedAppOp) {
logPrivateDataAccess(syncNotedAppOp.getOp(),
syncNotedAppOp.getAttributionTag(),
Arrays.toString(new Throwable().getStackTrace()));
}
@Override
public void onSelfNoted(@NonNull SyncNotedAppOp syncNotedAppOp) {
logPrivateDataAccess(syncNotedAppOp.getOp(),
syncNotedAppOp.getAttributionTag(),
Arrays.toString(new Throwable().getStackTrace()));
}
@Override
public void onAsyncNoted(@NonNull AsyncNotedAppOp asyncNotedAppOp) {
logPrivateDataAccess(asyncNotedAppOp.getOp(),
asyncNotedAppOp.getAttributionTag(),
asyncNotedAppOp.getMessage());
}
};
AppOpsManager appOpsManager = getSystemService(AppOpsManager.class);
if (appOpsManager != null) {
appOpsManager.setNotedAppOpsCollector(appOpsCollector);
}
}
また、Android 12をターゲットとするアプリの場合はこれらのアトリビューションタグをマニフェストファイル内で宣言する必要がある。
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="xxx.xxxx.myapplication">
...
<attribution
android:tag="sharePhotos"
android:label="@string/share_photos_attribution_label" />
...
</manifest>
宣言していないアトリビューション タグを使用しようとすると、attributionTagの値は null となった状態でLogcat に出力される。
5.5.3.6. 位置情報アクセスについて
Android OSにおける位置情報アクセスは、Android 10でのフォアグラウンドとバックグラウンドの分離をはじめ、段階的な権限リクエスト(Android 11)、おおよその位置情報の選択(Android 12)、Wi-Fiデバイス検出の権限分離(Android 13)など、バージョンを重ねるごとにプライバシー保護を目的とした権限モデルの細分化と厳格化が進められてきた。
しかし、これまでの権限モデルでは、位置情報を求めるたびに「アプリの使用中のみ許可」「今回のみ」「許可しない」といった3択ダイアログが表示されており、この選択肢はユーザーに都度判断を強いるため認知的負荷を与え、「ダイアログ疲れ」を引き起こす原因ともなっていた。
Android 17における「位置情報ボタン」の導入
Android 17で新たに導入された「位置情報ボタン」は、ユーザー体験(UX)とプライバシー保護を両立させる画期的な機能である。画面上に配置された「位置情報ボタン」をユーザー自らがタップする方式に変わることで、意思表示と権限付与が1ステップで完結し、操作のスムーズさが劇的に向上している。
この機能がもたらす最大のメリットは、「常時許可」から「データ最小化」への転換である。ボタンを1回タップするだけで、その時限りの正確な位置情報アクセスが許可される。例えば「SNSの投稿に場所をタグ付けする」「近くの店舗を検索する」といった操作では、タスクが完了すると権限も自動的に消失するため、ユーザーに対して「アプリが裏で勝手に追跡していない」という強い安心感を与えることができる。
位置情報ボタンは、Googleが提供するAndroid Jetpackのライブラリとして提供されるため、既存の画面レイアウトへ手軽に配置できる。また、高い後方互換性を持っており、Android 16以前のデバイスでユーザーがこのボタンをタップした場合には、自動的に従来型の位置情報リクエスト用ダイアログが表示される仕組みになっている。開発者はOSのバージョンごとに複雑な条件分岐を実装する必要がなく、シンプルなコードで最新のUIと従来の挙動を両立することが可能である。
図 5.5.8 Android16以下のデバイスで動作させた時の画面(左)、Android17のデバイスで動作させた時の画面(右)
5.5.3.7. ローカル ネットワークへのアクセス権
従来はINTERNET権限さえあればアプリが家庭内ネットワークの機器に自由にアクセスできたが、これにはユーザーの環境や利用状況の推測、個人識別、IoT機器の乗っ取りといったリスクがあった。この仕様変更の背景には、そうした不正なアクセスからユーザーのプライバシーとセキュリティを守る目的がある。ローカルネットワーク制限によって、悪意のあるアプリが無制限のLANアクセスを利用してユーザートラッキングやフィンガープリント採取を密かに行うことを防ぎ、ユーザーが明示的に許可しない限りアプリがLAN内の通信を行えなくなる。
Android 16におけるローカルネットワーク制限(オプトイン)
Android 16では、アプリがローカルネットワーク(LAN)上の機器と通信する際に NEARBY_WIFI_DEVICES 権限が必要になる制限が導入された。もともとAndroid 13で登場したこの権限の適用範囲が拡張され、LAN上のIPアドレス(192.168.x.xや10.x.x.xなど)へのTCP/UDP通信や、mDNSやNSDなどのローカルネットワークサービス探索も含めて、権限がなければアクセスできなくなる。ただし、この制限はデフォルトではオフになっており、開発者がadbコマンドで RESTRICT_LOCAL_NETWORK フラグを明示的に有効化した場合のみ適用される(オプトイン方式)。
Android 17におけるローカルネットワーク権限の必須化
Android 17以降では、この制限が全アプリに強制適用される。新たに ACCESS_LOCAL_NETWORK ランタイム権限が導入され、リクエストが必須となった。アプリが同じWi-Fi内の機器(スマートホームデバイス、メディアキャスト、ファイル共有など)と通信する場合、この権限を宣言して実行時にユーザーの許可を取得する必要がある。なお、この権限は既存の NEARBY_DEVICES 権限グループに該当するため、他のNEARBY_DEVICES権限をすでに付与しているユーザーには、再度権限の付与を求めるプロンプトは表示されない。
今後、全アプリに強制適用される見込みなので、LANアクセスを必要とするアプリは早めに対応・検証しておく必要がある。
実装と検証方法
まず、AndroidManifest.xmlにAndroid 16用の NEARBY_WIFI_DEVICES とAndroid 17用の ACCESS_LOCAL_NETWORK 、および INTERNET などのパーミッションを宣言する。
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Android 16用(オプトイン検証用) -->
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<!-- Android 17以上 ランタイムパーミッション -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
Android 16環境でローカルネットワーク制限を検証する場合は、adbコマンドで制限を有効化し、端末を再起動する。
adb shell am compat enable RESTRICT_LOCAL_NETWORK <package_name>
adb reboot
アプリ側では、たとえばJetpack Composeを使って以下のようなコードで接続を検証できる。ボタンを押すとLAN内のIPアドレスにソケット接続を試み、その結果を画面に表示する。
Button(
onClick = {
testResult = "テスト中..."
Thread {
try {
Socket().use { socket ->
socket.connect(InetSocketAddress("192.168.3.1", 80), 2000)
}
(context as? ComponentActivity)?.runOnUiThread {
testResult = "接続成功"
}
} catch (e: Exception) {
(context as? ComponentActivity)?.runOnUiThread {
testResult = "接続失敗: ${e.localizedMessage}"
}
}
}.start()
},
modifier = Modifier.fillMaxWidth()
) {
Text("ローカルネットワーク接続テスト")
}
権限が拒否されている状態(またはAndroid 16でオプトインし、権限を付与していない状態)でこのボタンを押すと、 sendto failed: EPERM (Operation not permitted) や sendto failed: ECONNABORTED (Operation not permitted) といったエラーが返る。実行時に権限を適切にリクエストし許可を得ると、LANアクセスが復活し、正常に通信できるようになる。
5.5.3.8. Android 12 における使用していないアプリの自動休止機能について
Android 12(API Level 31)をターゲットとするアプリの場合、Android 11から導入された権限の自動リセットに加え、一定期間使用していないアプリについては自動休止機能が適用される。これには以下のような特徴がある。
アプリのキャッシュ内のファイルはすべて削除されパフォーマンスではなく保存容量に合わせて最適化がおこなわれる
アプリはバックグラウンドでジョブやアラートを実行できなくなる
アプリはプッシュ通知(Firebase Cloud Messaging を介して送信される優先度の高いメッセージなど)を受信できなくなる
休止状態はユーザーが次にアプリを操作した場合に終了となる。ただし、アプリが休止状態になる前にスケジュールされたジョブ、アラート、通知は、スケジュールを再設定が必要となる。
権限の自動リセットや自動休止機能で不都合が生じるようなアプリ、たとえばデバイスとサーバーの間で定期的にデータを同期するようなアプリにおいては、ユーザーはアプリ情報画面の「権限を削除して空き容量を増やす」オプションをオフにすることで、適用対象から除外することができる。
試験的にアプリを休止状態に移行させるには、以下のコマンドを実行する。こうすることで休止状態になった状況をシミュレートできる。
休止状態の動作を有効にする
$ adb shell device_config put app_hibernation app_hibernation_enabled true
アプリを強制的に休止状態にする
$ adb shell cmd app_hibernation set-state org.jssec.android.activity.privateactivity true
5.5.3.9. パッケージアクセスの仕様変更にともなうAPIの戻り値の変化
デバイスにAndroid 12がインストールされ、且つ、ターゲットにAndroid 11(API Level 30)以降を指定する場合、パッケージアクセスの仕様変更にともない、以下のメソッドの戻り値はフィルタリングされた値となる。 これはAndroid 11においてなされた、パッケージアクセスの最小限の権限の原則に従ったものである。
getAllPermissionGroups()
getPermissionGroupInfo()
getPermissionInfo()
queryPermissionsByGroup()
動作確認として、以下のようなカスタムパーミッショングループを作成し、getAllPermissionGroups()の値がどのように変わるかを比較した。
<permission-group android:name="android.permission-group.JSSEC"
android:label="@string/perm_label"
android:icon="@drawable/ic_launcher_foreground"
android:description="@string/perm_description"
android:permissionGroupFlags="personalInfo"
android:priority="360"/>
デバイスにAndroid 11がインストールされ、ターゲットにAndroid 11(API Level 30)を指定した場合
com.google.android.gms.permission.CAR_INFORMATION
android.permission-group.CONTACTS
android.permission-group.PHONE
android.permission-group.CALENDAR
android.permission-group.CALL_LOG
android.permission-group.CAMERA
android.permission-group.UNDEFINED
android.permission-group.ACTIVITY_RECOGNITION
android.permission-group.SENSORS
android.permission-group.LOCATION
android.permission-group.STORAGE
android.permission-group.MICROPHONE
android.permission-group.SMS
android.permission-group.JSSEC
デバイスにAndroid 12がインストールされ、ターゲットにAndroid 11(API Level 30)を指定した場合
com.google.android.gms.permission.CAR_INFORMATION
android.permission-group.CONTACTS
android.permission-group.PHONE
android.permission-group.CALENDAR
android.permission-group.CALL_LOG
android.permission-group.CAMERA
android.permission-group.UNDEFINED
android.permission-group.ACTIVITY_RECOGNITION
android.permission-group.SENSORS
android.permission-group.LOCATION
android.permission-group.STORAGE
android.permission-group.MICROPHONE
android.permission-group.SMS
デバイスにAndroid 12がインストールされ、且つ、ターゲットにAndroid 11(API Level 30)を指定した場合は、カスタムパーミッショングループの取得が出来ていないことがわかる。 デバイスにインストールされたパッケージのリストもプライバシーという考えに基づく仕様であり、他のアプリにアクセスする必要がある場合は、<queries>により明示的に指定する必要がある。<queries>は本ガイドのいくつかのサンプルコードでも使用している。
5.5.3.10. Android 12 におけるマイクとカメラ
Android 12 では、クイック設定もしくはシステム設定の [プライバシー] 画面から、マイクとカメラのオン・オフが設定できる。この設定はデバイス上のすべてのアプリに対し反映される。
ユーザーが、マイクまたはカメラをオフにした設定した状態でそれらにアクセスするアプリを起動すると、システムからユーザーに対し、当該デバイスがオフになっていることが通知される。
この機能はサポートしているデバイスでのみ利用可能で、以下のコードにより確認できる。
SensorPrivacyManager sensorPrivacyManager = getApplicationContext().getSystemService(SensorPrivacyManager.class);
boolean supportsMicrophoneToggle = sensorPrivacyManager.supportsSensorToggle(Sensors.MICROPHONE);
boolean supportsCameraToggle = sensorPrivacyManager.supportsSensorToggle(Sensors.CAMERA);
マイクとカメラを利用するアプリにおいては、権限に沿って以下の方法によりアクセスすることが推奨される。
ユーザーがアプリに CAMERA 権限を付与するまで、デバイスのカメラにアクセスしない。
ユーザーがアプリに RECORD_AUDIO 権限を付与するまで、デバイスのマイクにアクセスしない。
アプリがマイクまたはカメラにアクセスすると、ステータスバーにアクセス中であることを示すアイコンが表示される。 ユーザーは、クイック設定からアイコンをタップすることにより、現在どのアプリがマイクまたはカメラを使用しているのかを確認できる。
5.5.3.12. 部分的画面共有
Android 15では、新たに部分的画面共有機能が導入された。この機能により、一部のアプリウィンドウのみを共有または録画することが可能になる。この変更は、プライバシー保護や特定のアプリケーションのコンテンツにフォーカスするニーズに対応することを目的としている。以下に、実装方法の手順を示す。
オーバーレイ表示の許可を要求する
アプリが他のアプリの上にオーバーレイ表示を行うための許可を取得する必要がある。以下のコードで、オーバーレイ表示の許可をユーザーに要求する。
private fun checkOverlayPermissionAndRequestMediaProjection() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (!Settings.canDrawOverlays(this)) {
val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName"))
overlayPermissionLauncher.launch(intent)
} else {
requestMediaProjectionPermission()
}
} else {
requestMediaProjectionPermission()
}
}
private val overlayPermissionLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (Settings.canDrawOverlays(this)) {
requestMediaProjectionPermission()
} else {
Toast.makeText(this, "Overlay permission denied", Toast.LENGTH_SHORT).show()
}
}
}
上記のコードを実行すると、アプリケーションが他のアプリの上にオーバーレイ表示を行うための許可をユーザーに要求するダイアログが表示される。
図 5.5.9 オーバーレイ表示を行うための許可
画面キャプチャの許可を要求し、その結果を処理する
オーバーレイ表示の許可が付与されたら、次にMediaProjectionManagerを使用して画面キャプチャの許可を要求し、その結果を処理する。
private fun requestMediaProjectionPermission() {
val captureIntent = mediaProjectionManager.createScreenCaptureIntent()
mediaProjectionLauncher.launch(captureIntent)
}
private val mediaProjectionLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
val serviceIntent = Intent(this, MediaProjectionService::class.java).apply {
putExtra("resultCode", result.resultCode)
putExtra("data", result.data)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
} else {
Toast.makeText(this, "Permission denied", Toast.LENGTH_SHORT).show()
}
}
上記のコードを実行すると、アプリケーションが他のアプリの画面キャプチャの許可をユーザーに要求するダイアログが表示される。
図 5.5.10 録画またはキャストすることを許可
キャプチャするアプリの選択
Startボタンをクリックするとアプリの一覧が表示され、キャプチャするアプリを選択できるようになる。以下はAndroid標準のClockアプリを選択した場合。
図 5.5.11 アプリの一覧
図 5.5.12 Clockアプリを選択
5.5.3.13. DNDモードのグローバル状態管理の変更点
Android 14以前では、setInterruptionFilterやsetNotificationPolicyを使用してDND(Do Not Disturb)モードのグローバル状態やポリシーを直接変更できた。
fun setInterruptionFilter(context: Context, filter: Int) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.setInterruptionFilter(filter)
}
Android 15以降では、アプリがデバイスのDNDモードのグローバル状態やポリシーを直接変更することはできなくなっており、代わりに、AutomaticZenRuleを使用することが推奨される。システムは、既存の中で最も制限の厳しいポリシーを優先するスキームで、これをグローバルポリシーに統合する。これは、悪意のあるアプリがユーザーの意図に反してDNDモードを勝手にオンオフするのを防ぎ、ユーザーの設定や他のアプリの重要なルールを保護するためである。
AutomaticZenRuleは、特定の条件が満たされたときに自動的にDNDモードを有効または無効にするルールであり、これにより、DNDモードを間接的に制御することができる。
ルールの作成
fun createAutomaticZenRule(context: Context) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Define the condition for the rule (e.g., a condition to enable DND at a specific time)
val conditionId = Uri.parse("content://com.example.myapp/condition")
// Create the AutomaticZenRule
val zenRule = AutomaticZenRule(
"My Automatic Zen Rule",
ComponentName(context.packageName, MyConditionConfigurationActivity::class.java.name),
conditionId,
null, // ZenPolicy is optional, pass null if not needed
NotificationManager.INTERRUPTION_FILTER_PRIORITY,
true
)
// Add the rule
val ruleUri = notificationManager.addAutomaticZenRule(zenRule)
if (ruleUri != null) {
println("AutomaticZenRule created: $ruleUri")
} else {
println("Failed to create AutomaticZenRule")
}
}
ルールの更新
fun updateAutomaticZenRule(context: Context, ruleId: String) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Change the condition for the rule
val newConditionId = Uri.parse("content://com.example.myapp/new_condition")
// Retrieve the existing rule
val zenRule = notificationManager.getAutomaticZenRule(ruleId)
if (zenRule != null) {
// Update the rule
val updatedZenRule = AutomaticZenRule(
zenRule.name,
zenRule.owner,
zenRule.configurationActivity,
newConditionId,
zenRule.zenPolicy,
zenRule.interruptionFilter,
zenRule.isEnabled
)
notificationManager.updateAutomaticZenRule(ruleId, updatedZenRule)
println("AutomaticZenRule updated: $ruleId")
} else {
println("AutomaticZenRule not found: $ruleId")
}
}
グローバル状態に影響していた既存のAPI(setInterruptionFilter、setNotificationPolicy)を呼び出した場合、暗黙的にAutomaticZenRuleが作成または更新される。このAutomaticZenRuleは、API呼び出しのサイクルに応じてオンまたはオフに切り替わる。
また、例えばsetInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_ALL)を使用してDNDモードをオフにしようとする場合も暗黙的にAutomaticZenRuleが作成され、システムが適切に管理する。
5.5.3.14. プライベート スペース
Android 15では、プライベートスペース機能が実装された。プライベートスペースは、デバイス上に安全で隔離された環境を作成し、アプリやデータをプライベートに管理できる仕組みである。このスペースは端末のメインユーザーに紐づく形で作成され、利用やアプリのインストールはユーザーが決定する。そのため、アプリ開発者側で特別なAPI対応や改修は原則不要だが、以下の仕様を把握しておくことが推奨される。
プライベートスペースはAndroidのマルチユーザーモデルに基づき、独立したユーザープロファイルとして動作する。アプリはこのプロファイル内にインストールされ、データも分離される。ただし、暗号化などの追加保護が自動的に適用されるわけではないため、通常のアプリケーション同様の対策が必要となる。
ストレージについては、プライベートスペース用の外部ストレージ領域が用意されており、メインユーザーとは異なる領域が割り当てられる。それぞれの領域のアプリは、原則として他の領域の外部ストレージに保存されたファイルへ直接アクセスできない。ロック解除時には、Sharesheetやシステムギャラリーピッカー経由でのみファイルのやり取りが可能となる。
プライベートスペースのロック状態によって、アプリの動作は大きく異なる。ロック中は、プライベートスペース内のすべてのアプリが完全に停止し、通知の表示やバックグラウンド処理も行われない。また、通知やアプリの存在自体もランチャーや設定画面などから隠される。ロック解除後にのみ、通知やアプリが表示され、利用可能となる。
このように、プライベートスペースはストレージ分離だけでなく、通知やアプリの可視性、バックグラウンド動作の制限など、ユーザーのプライバシー保護を多角的に実現している。特に、バックグラウンドでの動作や常時通知が必要なアプリ(例:医療系アプリ)については、プライベートスペース内での利用が推奨されない場合がある。
メインスペースとプライベートスペースでは、異なるGoogleアカウントを利用することも可能である。
このように、プライベートスペースはストレージのみならず、通知やアプリの動作、可視性など多岐にわたる仕様を持つ。ストレージのみを意識した対策では不十分であり、アプリ開発者はプライベートスペースの全体像を理解したうえで、必要に応じた対応やユーザーへの案内を検討することが望ましい。
5.5.3.15. Contact Pickerへの移行と連絡先データの適切な取り扱い
Android 17の連絡先プロバイダ(CP2)の厳格化に伴い、「連絡先はユーザーが選んだ分だけ共有する」モデルが推奨されている。これまでREAD_CONTACTS権限を利用して行われていた連絡先へのフルアクセスは大幅に制限され、Contact Pickerによるユーザー選択型アクセスが基本となる。アプリは連絡先にフルアクセスする代わりにContact Pickerを利用し、ユーザーが個別に選択・許可したデータ(電話番号、メールアドレス、名前、住所、会社名・役職など)のみを取得する設計へ移行する必要がある。
この制限の背景には、アプリによる情報の過剰収集の見直しがある。従来の永続的なフルアクセスから、必要最小限の一時アクセスへと方針が転換された。通話アプリや連絡先管理アプリのように、どうしても連絡先へのフルアクセスが必要な場合は審査対象となり、Google Play Consoleにて正当な理由の申告が求められる。
また、Contact Pickerで取得した情報は一時的な利用を前提とし、不要な長期間の保存やバックグラウンドでの継続的な利用は避けるべきである。ユーザーに明示することなく連絡先データを保存したりアップロードしたりする行為は、ユーザーデータポリシー違反とみなされる可能性がある。取得したデータは利用目的を限定し、データを保存せざるを得ない場合でも、その用途と範囲をユーザーに対して事前に明確に説明しなければならない。
ユーザー体験(UX)の観点からも、アプリ起動時にいきなり権限やデータの提供を要求する実装は避けるべきである。データを取得する前に、なぜそのデータが必要なのかを分かりやすく説明し、ユーザーの自発的な操作をトリガーとしてContact Pickerを起動する設計にすることが、プライバシー保護とユーザーからの信頼獲得において重要である。
実装にあたっては、Android 17以上の環境では既存の Intent.ACTION_PICK を用いた呼び出しも自動的に新しいContact Pickerのインターフェースへとアップグレードされる。しかし、複数のデータ種別を同時に指定するなど、新しいContact Pickerのフル機能を利用するためには、新設された Intent.ACTION_PICK_CONTACTS を明示的に使用することが推奨される。
// Android 17以降で推奨されるContact Pickerの起動例
val intent = Intent(Intent.ACTION_PICK_CONTACTS)
// ※必要に応じて、取得したいデータ種別(電話番号やメールアドレスなど)をエクストラで指定する
startActivityForResult(intent, REQUEST_CODE)
5.6. 暗号技術を利用する
セキュリティの世界では、脅威を分析し対策するための観点として、機密性(Confidentiality)、完全性(Integrity)、可用性(Availavility)という用語が使われる。それぞれ秘密のデータを第三者に見られないようにすること、参照するデータが改ざんされないように(または、改ざんされたことを検知)すること、サービスやデータが必要な時にいつでも利用できることを意味しており、セキュリティを確保する際に考慮すべき大切な要素である。中でも、機密性や完全性のために暗号技術が用いられることが多く、Androidにおいてもアプリが機密性や完全性を実現するための様々な暗号機能が用意されている。
ここでは、Androidアプリにおける暗号化・復号(機密性の実現)、メッセージ認証コード/デジタル署名 (完全性の実現)の安全な実装方法をサンプルコードで示す。
5.6.1. サンプルコード
「暗号化・復号する(機密性を確保する)」、「改ざんを検知する(完全性を確認する)」というユースケースの具体的な用途や条件に対して、様々な暗号方式が開発されている。ここでは、暗号技術をどのような用途で利用するかという観点から、暗号方式を大きく3つに分類してサンプルコードを用意している。それぞれの暗号技術の特徴から利用すべき暗号方式・鍵の種類を判断することができる。より細やかな判断が必要な場合には、「5.6.3.1. 暗号方式の選択」も参照すること。
第三者の盗聴からデータを守る
図 5.6.1 盗聴からデータを守るサンプルコードを選択するフローチャート
第三者によるデータの改ざんを検知する
図 5.6.2 データの改ざんを検知するサンプルコードを選択するフローチャート
5.6.1.1. パスワード鍵を利用して暗号化・復号する
ユーザーの情報資産の秘匿性を守る目的にはパスワードベース鍵暗号を使うことができる。
ポイント:
明示的に暗号モードとパディングを設定する
脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
パスワードから鍵を生成する場合は、Saltを使用する
パスワードから鍵を生成する場合は、適正なハッシュの繰り返し回数を指定する
十分安全な長さを持つ鍵を利用する
AesCryptoPBEKey.java
/*
* Copyright (C) 2012-2026 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. 公開鍵を利用して暗号化・復号する
アプリ側では公開鍵を保持してデータの暗号化のみを行い、復号を異なる安全な場所(サーバーなど)で秘密鍵によって行うような用途には公開鍵暗号を使うことができる。
ポイント:
明示的に暗号モードとパディングを設定する
脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
十分安全な長さを持つ鍵を利用する
RsaCryptoAsymmetricKey.java
/*
* Copyright (C) 2012-2026 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. 共通鍵を利用して暗号化・復号する
サイズの大きなデータを扱う場合やアプリの資産・ユーザーの資産の秘匿性を守る目的で使うことができる。
ポイント:
明示的に暗号モードとパディングを設定する
脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
十分安全な長さを持つ鍵を利用する
AesCryptoPreSharedKey.java
/*
* Copyright (C) 2012-2026 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. パスワード鍵を利用して改ざんを検知する
ユーザーの情報資産の完全性をチェックする目的にはパスワードベース(共通鍵)暗号を使うことができる。
ポイント:
明示的に暗号モードとパディングを設定する
脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
パスワードから鍵を生成する場合は、Saltを使用する
パスワードから鍵を生成する場合は、適正なハッシュの繰り返し回数を指定する
十分安全な長さを持つ鍵を利用する
HmacPBEKey.java
/*
* Copyright (C) 2012-2026 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. 公開鍵を利用して改ざんを検知する
異なる安全な場所(サーバーなど)で秘密鍵による署名を行ったデータに対して、アプリ側では公開鍵を保持してデータの署名検証のみを行うような用途には公開鍵暗号を使うことができる。
ポイント:
明示的に暗号モードとパディングを設定する
脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
十分安全な長さを持つ鍵を利用する
RsaSignAsymmetricKey.java
/*
* Copyright (C) 2012-2026 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. 共通鍵を利用して改ざんを検知する
アプリの資産・ユーザーの資産の完全性をチェックする目的で使うことができる。
ポイント:
明示的に暗号モードとパディングを設定する
脆弱でない(基準を満たす)暗号技術(アルゴリズム・モード・パディング等)を使用する
十分安全な長さを持つ鍵を利用する
HmacPreSharedKey.java
/*
* Copyright (C) 2012-2026 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. ルールブック
暗号技術を利用する際には以下のルールを守ること。
5.6.2.1. 暗号を指定する場合は、明示的に暗号モードとパディングを設定する (必須)
暗号化やデータ検証などで暗号技術を利用する場合、明示的に暗号モードとパディングを設定すること。Androidアプリの開発で暗号技術を利用する場合、主に java.crypto のクラスである Cipher クラスを利用する。Cipherクラスを利用する際、初めにどのような暗号を利用するかを指定してCipherクラスのオブジェクトを生成する。この指定をTransformationと呼ぶが、Transformationの指定の書式には以下の2種類が存在する
"algorithm/mode/padding"
"algorithm"
後者の場合、暗号モードとパディングは、Androidで利用可能な暗号化サービスプロバイダ固有のデフォルト値が使用される。このデフォルト値は、利便性や互換性などを優先し、あまり安全でないものが設定されていることがあり、セキュリティを確保するためには必ず、前者の暗号モードとパディングを指定する書式を利用する必要がある。
5.6.2.2. 脆弱でないアルゴリズム(基準を満たすもの)を使用する (必須)
暗号技術を利用する場合、アルゴリズムとして脆弱でない、一定の基準を満たしたものを使用すること。また、アルゴリズムとして複数の鍵長を許容する場合、アプリの製品ライフタイムを考慮して十分安全な鍵長を利用すること。さらには、暗号モードやパディングモードについても既知の攻撃方法が存在するものもあり、脆弱でない十分安全な方式を利用すること。
脆弱な暗号技術を利用した場合、例えば第三者からの盗聴を防ぐために暗号化したファイルでも、有効な保護になっておらず、第三者の盗聴を許してしまう為である。IT技術の進化に伴い、暗号解析の技術も向上されるため、利用する技術はアプリが稼働することを想定する期間中、安全が保護されることが期待できるアルゴリズムを検討し選択する必要がある。
アルゴリズムのライフタイム(特定の暗号化アルゴリズムによって保護されたデータが安全なままである推定期間)や暗号技術に関して、以下に示す各国の基準を参考にすること。
アルゴリズムのライフタイム |
共通鍵暗号 |
公開鍵暗号(例:RSA, DSA, DH) |
楕円曲線暗号(例:ECDSA) |
ハッシュ値(デジタル署名、ハッシュ関数) |
ハッシュ値(HMAC、鍵導出関数、乱数生成器) |
レガシー(最低暗号強度80bit以下) |
80 |
1024 |
160 |
160 |
- |
~2030年(最低暗号強度112bit以上) |
112 |
2048 |
224 |
224 |
- |
2030年~(最低暗号強度128bit以上) |
128 |
3072 |
256 |
256 |
160 |
単位:bit
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)
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)
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)
アルゴリズムのライフタイム |
共通鍵暗号 |
公開鍵暗号 |
楕円曲線暗号 |
ハッシュ値 |
保護期間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
“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)
技術分類 |
名称 |
|
公開鍵暗号 |
署名 |
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 |
|
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. パスワードの強度を高める工夫をする (推奨)」も参照すること。
公開鍵 |
共通鍵 |
パスワードベース |
|
サイズの大きなデータの処理 |
NG(処理コストが高い) |
OK |
OK |
アプリ(サービス)資産の保護 |
OK |
OK |
NG(ユーザーによる盗聴が可能) |
ユーザー資産の保護 |
OK |
OK |
OK |
暗号の強度 |
鍵の長さに依存 |
鍵の長さに依存 |
パスワードの強度、Salt、 ハッシュの繰り返し回数に依存 |
鍵の保護 |
容易(公開鍵のみ) |
困難 |
容易 |
アプリで行う処理 |
暗号化(復号はサーバーなど) |
暗号化・復号 |
暗号化・復号 |
改ざん検知における暗号方式の比較
データのサイズの項を除いた以外は、暗号化・復号における暗号方式の比較とほぼ同様である。
公開鍵 |
共通鍵 |
パスワードベース |
|
アプリ(サービス)資産の 保護 |
OK |
OK |
NG(ユーザーによる改ざんが 可能) |
ユーザー資産の保護 |
OK |
OK |
OK |
暗号の強度 |
鍵の長さに依存 |
鍵の長さに依存 |
パスワードの強度、Salt、 ハッシュの繰り返し回数に依存 |
鍵の保護 |
容易(公開鍵のみ) |
容易 |
|
アプリで行う処理 |
署名検証(署名は サーバーなど) |
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が選ばれる。 暗号学的に安全ではない [31] SHA1PRNG アルゴリズムを提供する Crypto Provider は、Android 7.0(API Level 24) で非推奨となり、Android 9.0(API Level 28)で削除された [32] [33] [34]。 Crypto Provider を指定してSecureRandomを使うと、Android 9.0 以降の端末では常に、Android 7.0 以降の端末でも targetSdkVersion>=24 であれば、NoSuchProviderException が発生する。 そのため、原則としてProviderを指定しないでSecureRandomを使うことを推奨する。以下にSecureRandomの使い方の例を示す。
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)
Security "Crypto" provider deprecated in Android N (https://android-developers.googleblog.com/2016/06/security-crypto-provider-deprecated-in.html)
Cryptography Changes in Android P (https://android-developers.googleblog.com/2018/03/cryptography-changes-in-android-p.html)
SecureRandom (https://developer.android.com/reference/java/security/SecureRandom)
SecureRandomの使用(デフォルトの実装を使用する)
import java.security.SecureRandom;
// ~省略~
SecureRandom random = new SecureRandom();
byte[] randomBuf = new byte [128];
random.nextBytes(randomBuf);
// ~省略~
SecureRandomのようなプログラムで実現されている疑似乱数生成器は一般に「図 5.6.3 疑似乱数生成器の内部プロセス」のように動作しており、乱数の種を入力して内部状態を初期化すると、乱数を生成する度に内部状態を一定のアルゴリズムで更新することで、次々と乱数列の生成が可能になる。
図 5.6.3 疑似乱数生成器の内部プロセス
乱数の種
疑似乱数生成器において乱数の種の役割は極めて重要である。
既に説明したように疑似乱数生成器は乱数の種による初期化が必要である。また、乱数の種で初期化された後は決められたアルゴリズムで乱数の生成が行われるので、同じ乱数の種からは同じ乱数列が生成されることになる。これは乱数の種が第三者に知られ(盗聴され)たり、予測可能であったりすると、第三者が同じ乱数列を取得することになり、乱数を起点にした機密性や完全性が失われることを意味している。
そのため、乱数の種はそれ自身機密性の高い情報であり、かつ予測困難でなければならない。例えば時刻情報や端末固有値(MACアドレス、IMEI、Android IDなど)を乱数の種として使うべきではない。多くのAndroid端末においては、/dev/urandom, /dev/random等が利用可能であり、デフォルトで提供されるSecureRandomの実装もそれらのデバイスファイルを使って乱数の種を設定している。また、機密性に関しては、乱数の種がメモリ内にのみ存在するならば、root権限を取得したマルウェア・ツールでない限り、盗聴の危険性は低い。仮に、root化された端末でも安全にする必要の場合は、セキュア設計・実装の専門家と相談して対応すること。
疑似乱数生成器の内部状態
疑似乱数生成器の内部状態は、乱数の種によって初期化され、乱数を生成する毎に状態が更新される。また、乱数の種と同様に、同じ内部状態の疑似乱数生成器が存在した場合は、その後に生成される乱数列はどちらもまったく同じものになる。よって、内部状態も第三者に対して、盗聴されないように気を付けなければならない。ただし、内部状態はメモリ内に存在するため、root権限を取得したマルウェア・ツールでない限り、盗聴の危険性は低い。root化された端末でも安全にする必要の場合は、セキュア設計・実装の専門家と相談して対応すること。
5.6.3.3. 鍵の保護
センシティブな情報の安全性(機密性、完全性)を実現するために暗号技術を利用する際に、堅牢な暗号化アルゴリズムや鍵長を選んでも、鍵そのもののデータが簡単に取得できる状態では、第三者から情報の安全性を保つことはできない。そのため、暗号技術を利用する際の鍵の取り扱いは、もっとも重要な検討項目の一つである。一方で、守るべき資産のレベルによっては鍵の取り扱いは非常に高度な設計・実装を必要とするため本ガイドの範囲を超える。そこで本ガイドでは、鍵の配置場所と用途ごとに安全な鍵の取り扱いをするための基本的な考え方を示すにとどめ、具体的な実現方法に関しては言及しない。必要に応じてAndroidセキュリティ設計・実装に詳しい専門家に相談することをお勧めする。
まずは、Androidスマートフォン/タブレットにおいて暗号化等に使う鍵が存在する可能性のある場所とその保護方針の概観を「 図 5.6.4 暗号鍵が存在する場所と保護方針」に示す。
図 5.6.4 暗号鍵が存在する場所と保護方針
また、鍵によって守る資産の資産分類やオーナーによる保護方針の違いを表にまとめておく。資産レベルについては「3.1.3. 資産分類と保護施策」を参照すること。
資産のオーナー |
端末のユーザー |
アプリ・サービス提供者 |
||
資産レベル |
高位 |
中低位 |
高位 |
中低位 |
鍵の保存場所 |
保護方針 |
|||
ユーザーの記憶 |
パスワードの強度の向上 |
ユーザーパスワードの利用不可 |
||
アプリディレクトリ (非公開ストレージ) |
鍵データの 暗号化・難読化 |
アプリ以外から の読み書き禁止 |
鍵データの 暗号化・難読化 |
アプリ以外から の読み書き禁止 |
鍵の保存場所がAPKファイルやSDカードなどの公開ストレージの場合は次のようになる。
鍵の保存場所 |
保護方針 |
APKファイル |
鍵データの難読化 (ProguardなどJavaの難読化ツールの多くは、データ(文字)列を難読化しないことに注意) |
SDカードなど (公開ストレージ) |
鍵データの暗号化・難読化 |
以下、鍵の存在場所ごとに保護方針の補足を示す。
ユーザーの記憶
この項ではパスワードベース暗号を想定している。パスワードから生成される鍵の場合は、鍵の保管場所はユーザーの記憶の中にあるため、保存場所からマルウェア等に漏洩することはない。ただし、パスワードの強度によっては簡単に鍵が再現されてしまうため、サービスへのログインパスワード等と同様にユーザーに対して一定のパスワード強度になるようにUIによって制限したり、メッセージによる注意喚起したりすることが必要となる。「5.5.2.6. アプリ・プライバシーポリシー概要版をassetsフォルダ内に配置しておく (推奨)」も参照すること。また、ユーザーの記憶にあるパスワードは忘れる可能性も考慮にいれておかなければならない。パスワードを忘れた場合でもデータの復元が必要な場合は、バックアップデータを端末以外の安全な場所(サーバーなど)に保存しておく必要がある。
アプリディレクトリ内
アプリのディレクトリ内にPrivateモードで保存した場合は他のアプリから鍵データを読み取られることはない。また、バックアップ機能をアプリで無効にした場合には、ユーザーも参照できなくなるので、アプリの資産を守る鍵をアプリディレクトリ内に保存する場合はバックアップを無効にすれば良い。
ただし、root権限を持つアプリやユーザーからも鍵を保護したい場合は、鍵データを暗号化もしくは難読化する必要がある。ユーザー資産を守る鍵を暗号化する場合にはパスワードベース暗号が利用できる。ユーザーにも秘密にしたいアプリの資産を暗号化する鍵を保護する場合には、APKファイルに鍵暗号化鍵を埋める必要があり、鍵暗号化鍵データを難読化する必要がある。
APKファイル内
APKファイル内に存在するデータは読み取り可能であるため、基本的には鍵のような秘匿データを含めるべきではない。鍵を含める場合は鍵データを難読化して、容易にAPKファイルから鍵データが取得されないように対策を講じる必要がある。
公開ストレージ(SDカードなど)内
公開ストレージはすべてのアプリから読み取り可能なため、基本的には鍵のような秘匿データを含めるべきではない。鍵を含める場合は鍵データを暗号化・難読化して容易に鍵データが取得されないように対策する必要がある。「アプリディレクトリ内」のroot権限を持つアプリやユーザーからも鍵を保護したい場合の対策も参照すること。
プロセスメモリ内の鍵の扱い
Androidの持つ暗号技術を使用する場合、上図におけるアプリプロセス以外の場所で暗号化もしくは難読化されていた鍵データは暗号処理の手前で必ず復号(パスワードベース鍵の場合は生成)が必要なため、プロセスメモリ内に鍵データが生のまま存在することになる。一方、アプリプロセスのメモリは通常別のアプリから読み書きすることはできないので、資産分類が本ガイドの対象範囲であれば、特別な対策を講じる必要性は少なく安全と言える。もし、アプリの扱う資産レベルや用途からプロセスメモリ内であっても鍵データがそのまま出現してはならない場合は、鍵データ・暗号ロジックの難読化など対策が必要であるが、一般にJava層での実現は難しく、JNI層での難読化ツールを使用することになる。これらの対応は本ガイドの範囲を超えるため、セキュア設計・実装の専門家と相談して対応すること。
5.6.3.4. 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.6.3.5. Conscryptモジュールについて
Conscrypt モジュールは、メーカーが用意するOTAアップデートに依存することなく、暗号技術関連の実装で明らかになった脆弱性への修正に利用できるAPEX ファイルである。
Android 9には、Conscrypt用の Android固有の公開APIは含まれていないが、Android 10では、少数の公開APIメソッドがandroid.net.sslに追加され、javax.net.sslの下のクラスが非公開の Conscrypt 機能にアクセスできるようになっている。
Conscrypt モジュールはGoogleがOpenSSLからforkしたネイティブ ライブラリBoringSSLを使用しており、多くのGoogleプロダクトで暗号とTLSに使用されている。
もともと、以下のように特定のプロバイダを明示的にリクエストすることは非推奨であったが、特にBouncyCastleプロバイダで実装された暗号化アルゴリズムについては、Android 12からは削除されている [35]。
執筆時点ではビルド時に警告が出ることもなくAndroid 12エミュレータ上でも問題なく実行できることを確認しているが非推奨であることには違いはない
Cipher.getInstance("AES/CBC/PKCS7PADDING", "BC");
// OR
Cipher.getInstance("AES/CBC/PKCS7PADDING", Security.getProvider("BC"));
この変更に影響を受けるアプリは以下のようなものである。
KeyGenerator で無効な鍵サイズを使用しているアプリ
初期化ベクトルのバイトサイズに12バイト以外のサイズを使用して Galois/Counter Mode(GCM)暗号を初期化しているアプリ
1について、Conscryptでサポートしている鍵サイズは 128 ビット、192 ビット、256 ビットとなっており、それ以外の鍵サイズを指定した場合、KeyGenerator.init()メソッド実行時に例外が発生する。このような場合、サポートされた適切な鍵サイズに修正する必要がある。
keygen.init(512, random); // 例外発生. Caused by: java.security.InvalidParameterException: Key size must be either 128, 192, or 256 bits
2について、GCM暗号を使用する場合、初期化ベクトルのバイトサイズは12バイトである必要がある。それ以外のサイズ、たとえば16バイトを指定していた場合、Cipher.init()メソッド実行時に例外が発生する。このような場合、サポートされた適切な初期化ベクトルのバイトサイズに修正する必要がある。
SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
byte[] INITIALV = new byte[16]; // 初期化ベクトルのバイトサイズを16に指定
random.nextBytes(INITIALV);
~
byte[] iv = INITIALV;
SecretKeySpec skey = new SecretKeySpec(key, CIPHER);
IvParameterSpec ivp = new IvParameterSpec(iv);
Cipher cipher = Cipher.getInstance("AES_256/GCM/NOPADDING"); // GCM暗号化アルゴリズム
cipher.init(Cipher.ENCRYPT_MODE, skey, ivp); // 例外発生. java.security.InvalidAlgorithmParameterException: Expected IV length of 12 but was 16
5.6.3.6. バックアップデータの漏洩対策
攻撃者がバックアップファイルへのアクセス権を得ると、アプリでバックアップされた暗号化されていないデータが取得される可能性がある。アプリのバックアップ機能が不要と考えられる場合、バックアップの実行を防ぐためにマニフェストファイルに以下の設定を推奨する。
<application android:name="com.example.foo" android:allowBackup="false">
...
</application>
Androidバージョン12以降では、allowBackupをfalseに設定すると、クラウドとADBのバックアップは制限されるが、デバイス間の移行は許可される仕様となっている。
アプリの仕様としてバックアップが必要な場合、バックアップするデータの種類を以下のように指定する(Android 12以降)。
<application android:name="com.example.foo"
android:dataExtractionRules="backup_rules.xml">
--
</application>
<application android:name="com.example.foo"
android:fullBackupContent="@xml/backup_rules">
--
</application>
詳細については以下のAndroid Developerガイドを参照。[36]
https://developer.android.com/privacy-and-security/risks/backup-leaks?hl=ja
5.6.3.7. アプリ内ハードコーディングのセキュリティリスク
開発者は暗号を使用してデータの機密性と完全性を保護することができるが、キーストレージを適切に活用せずにアプリのコードやアセットファイルにハードコードされているケースが多い。これにはセキュリティ上の重大な問題がある。
ハードコードされたクレデンシャルや暗号シークレットは、リバースエンジニアリングツールを使うことで簡単に取得されてしまう。これにより、攻撃者が機密データにアクセスすることが可能となり、セキュリティ上の重大なリスクが発生する。
そのため、システムレベルの認証情報が必要な場合はKeyChain APIを使用し、各アプリ専用の認証情報はAndroid KeyStoreプロバイダを利用して保存することが推奨される。特に、Android 16以降ではSecure Element搭載端末においてStrongBox対応が必須となるケースが増えており、今後クレデンシャルをKeyChainやKeyStoreに保存することが一般的な実践となることが予想される。これらの機能を活用することで、ハードコーディングによるリスクを効果的に低減できる。詳細はAndroid Developerガイドを参照。 [37]
https://developer.android.com/privacy-and-security/risks/hardcoded-cryptographic-secrets
実装方法
Android KeyStoreプロバイダを使用してアプリ固有の認証情報を保存
Android KeyStoreを使用して対称鍵(AES鍵)を生成し保存する。
companion object {
private const val ANDROID_KEY_STORE_PROVIDER = "AndroidKeyStore"
private const val ANDROID_KEY_STORE_ALIAS = "AES_KEY_DEMO"
}
@Throws(
KeyStoreException::class,
NoSuchAlgorithmException::class,
NoSuchProviderException::class,
InvalidAlgorithmParameterException::class
)
private fun createAndStoreSecretKey() {
val builder: KeyGenParameterSpec.Builder = KeyGenParameterSpec.Builder(
ANDROID_KEY_STORE_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
val keySpec: KeyGenParameterSpec = builder
.setKeySize(256)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build()
val aesKeyGenerator: KeyGenerator =
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE_PROVIDER)
aesKeyGenerator.init(keySpec)
aesKeyGenerator.generateKey()
}
@Throws(
KeyStoreException::class,
UnrecoverableEntryException::class,
NoSuchAlgorithmException::class,
CertificateException::class,
IOException::class,
NoSuchPaddingException::class,
InvalidKeyException::class,
IllegalBlockSizeException::class,
BadPaddingException::class
)
private fun encryptWithKeyStore(plainText: String): ByteArray? {
// Initialize KeyStore
val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEY_STORE_PROVIDER)
keyStore.load(null)
// Retrieve the key with alias created before
val keyEntry: KeyStore.SecretKeyEntry =
keyStore.getEntry(ANDROID_KEY_STORE_ALIAS, null) as KeyStore.SecretKeyEntry
val key: SecretKey = keyEntry.secretKey
// Use the secret key at your convenience
val cipher: Cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key)
return cipher.doFinal(plainText.toByteArray())
}
KeyChain APIを使用してシステムレベルの認証情報を管理
KeyChain APIを使用してシステム全体で共有される証明書と秘密鍵を管理する
private fun selectCertificate() {
KeyChain.choosePrivateKeyAlias(this, object : KeyChainAliasCallback {
override fun alias(alias: String?) {
if (alias != null) {
// Use the alias to retrieve the certificate
try {
val privateKey = KeyChain.getPrivateKey(this@MainActivity, alias)
val certificateChain = KeyChain.getCertificateChain(this@MainActivity, alias)
println("Selected Certificate Alias: $alias")
// Example usage of the private key and certificate chain
// Typically, you would use these for SSL/TLS connections or signing operations
} catch (e: KeyChainException) {
e.printStackTrace()
} catch (e: InterruptedException) {
e.printStackTrace()
}
}
}
}, null, null, null, -1, null)
}
クレデンシャルの取得と暗号化、証明書の選択
リモートサーバからAPIキーを取得し、暗号化した後、システムレベルの証明書を選択して使用する
try {
createAndStoreSecretKey()
val apiKey = fetchApiKeyFromServer()
val encryptedData = encryptWithKeyStore(apiKey)
// Do something with the encrypted data (e.g., log output)
println("Encrypted API Key: ${encryptedData?.joinToString(", ")}")
// Use KeyChain to select and use a system-level certificate
selectCertificate()
} catch (e: Exception) {
e.printStackTrace()
}
StrongBox搭載端末の普及が今後も進むことで、KeyStoreおよびKeyChain APIとの相乗効果が期待される。StrongBoxはハードウェアベースのセキュリティモジュールとして、これらのAPIが管理する暗号鍵や認証情報の保護を強化し、全体的なセキュリティレベルを向上させる。StrongBox未搭載の端末においても、KeyStoreおよびKeyChain APIを活用することで、Trusted Execution Environment(TEE)などのソフトウェアベースのセキュリティ機構を通じて十分なセキュリティ向上が可能である。将来的にStrongBox搭載端末への移行が進むことを見越し、現段階からこれらのAPIを積極的に導入することが推奨される。これにより、ハードコーディングによるセキュリティリスクを効果的に低減し、堅牢なアプリケーション開発を継続的に実現できる。
5.6.3.8. Key sharing APIによるアプリ間鍵共有の仕組みとリスク
Android 16では、KeyStoreで作成した鍵を、他のアプリと共有するためのKey sharing APIが追加された。KeyStoreManagerクラスに、これらのAPIは設けられている。 鍵の許可側アプリは、grantKeyAccess(String alias, int uid)で鍵へのアクセス許可、revokeKeyAccess(String alias, int uid)で鍵からアクセス許可のはく奪を行う。aliasは作成した鍵のエイリアス、uidは鍵の使用を許可する被許可側アプリのuidである。 鍵の被許可側アプリは、getGrantedKeyFromId(long id)または、getGrantedKeyPairFromId(long id)により、鍵を指定する。このidは、grantKeyAccessの返り値である。そのため、許可側アプリと被許可側アプリの間で、このidの受け渡しを行う必要がある。 本来、Android KeyStoreプロバイダを用いて作られた鍵は、生成したアプリ内でのみ使用できるということが特徴であり、Key sharing APIはアプリのセキュリティレベルを低下させる恐れがある。そのため、Key sharing APIの利用に際しては、以下の点に注意し、ユースケースを検討したうえでセキュリティ対策やリスクの受容を行う必要がある。
許可するアプリの信頼性の確認:許可するアプリの指定は、uidで行う。つまりユーザーの端末ごとに異なる値であることから、固定値での指定はできない。パッケージを指定してuidを確認する際には、インストールされているアプリが正当性を検証する必要がある。許可するアプリとの連携する場合は、4.1.2.10. 他社の特定アプリと連携する場合は利用先Activityを確認する(必須) 等を参考に確認方法を実装する必要がある。
許可したアプリの管理:鍵の利用を許可した対象を取得するようなAPIは提供されていない。そのため、許可したアプリを管理する必要がある場合は、独自にリストを作成し、保有しておく必要がある。
許可の期限:鍵の許可をいつまで継続させるかも検討する必要がある(自動的に許可を取り消す機能はない)。鍵を許可した後に、許可したアプリの更新時にマルウェアが紛れ込む、または脆弱性が発見され利用されることで、本来想定していない形で鍵へのアクセスが行われる可能性がある。そのため、ユースケースにもよるが、必要に応じて一定期間経過後に許可を取り消すようなロジックを組み込むことも検討されたい。
5.6.3.9. (コラム) Google Playアプリ署名による鍵管理
AndroidのPlayアプリ署名(Google Play App Signing)は、Google Playストアにアプリを公開する際に重要なセキュリティ機能である。 アプリ署名は、開発者がアプリのAPKファイルにデジタル署名を付与するプロセスであり、このデジタル署名によってアプリが改ざんされていないことを証明できる。ユーザーがアプリをインストールする際、Androidシステムは署名を検証し、信頼できるアプリかどうかを判断する。
Playアプリ署名(Google Play App Signing)は、開発者がアプリの署名をGoogleに委任できるサービスである。このサービスを利用すると、Googleがアプリのキー管理と署名を担い、より高いレベルのセキュリティを実現できる。開発者は最初にアップロードするAPKにのみデジタル署名を行い、その後のバージョン管理や署名はGoogleが実施する。
署名の手順としては、まず開発者が自身のマシンでキーストアを生成し、その中の秘密鍵でAPKファイルにデジタル署名を行う。この作業はAndroid Studioやコマンドラインツールを使って実行できる。署名済みAPKファイルをGoogle Playコンソールにアップロードすることで、アプリの公開準備が整う。Google Play署名サービスを利用する場合、最初のアップロードのみ開発者が署名し、その後のバージョンはGoogleが署名を代行する。
Google Play署名サービスを利用することで、開発者はキー管理のリスクを軽減できる。Googleは高度なセキュリティ対策を講じており、キーの安全性を確保しているため、不正アクセスや改ざんからアプリを保護できる。
また、Google Play署名サービスで署名されたアプリは、ユーザーから信頼されやすくなる。これにより、ダウンロード数の増加やポジティブなレビュー獲得が期待できる。さらに、署名済みアプリはGoogle Playプロテクトによるスキャン対象となり、セキュリティが強化される。
AndroidのPlayアプリ署名は、アプリのセキュリティと信頼性を確保するために不可欠な機能である。Google Play署名サービスを活用することで、開発者はキー管理の負担を軽減し、ユーザーに信頼性の高いアプリを提供できる。この仕組みを理解し、適切に活用することが、安全なアプリ開発につながる。
なお、署名鍵の管理が不十分な場合、実際に流出や悪用が発生した事例も報告されている。具体的な事例については以下を参照。
https://gigazine.net/news/20221203-samsung-android-signing-key-leaked/?utm_source=chatgpt.com
5.6.3.10. アプリごとのAndroid Keystore(鍵)上限
Android 17では、プラットフォームの安定性とセキュリティ向上を目的として、アプリごとに使用できるAndroid Keystore内の鍵数に上限が導入された。具体的には、システムアプリ以外のアプリにつき最大で50,000個までの鍵(エントリ)しか作成できないという制限である。
この上限は、Androidが提供するAndroidKeyStoreシステムに格納される鍵のみが対象となる。KeyGenParameterSpecなどを用いて生成され、AndroidKeyStoreプロバイダに保存された鍵がカウント対象であり、AES鍵やRSA鍵ペア、EC鍵など、いずれもalias単位で1つの鍵として数えられる。一方で、アプリの内部ストレージに保存されるBKSやJKS形式のファイル型キーストアや、独自の暗号ライブラリで管理する鍵などは、この上限には含まれない。あくまでOSが管理するAndroidKeyStore領域のリソース制限という位置づけである。
この変更の背景には、アプリが意図せず大量の鍵を生成し続けることで、デバイス全体のリソースを圧迫する問題を防ぐ狙いがある。Android 17ではメモリやリソース管理の強化が複数導入されており、その一環としてKeystoreの使用量にも制限が設けられている。上限を超える鍵を作成しようとすると、KeyStoreExceptionが発生して作成に失敗する。
実務上、多くの一般的なアプリでは鍵の数は数個から数十個程度に収まるため、この上限に到達するケースはほとんどない。しかし、設計によっては注意が必要である。たとえば、ユーザーごとに個別の鍵を生成する設計や、ログインや処理ごとに新しいaliasを作る実装の場合、ユーザー数や処理回数に比例して鍵が増加し、最終的に上限に達するリスクがある。
そのため、Android 17以降では次のような設計が推奨される。まず、Keystoreにはアプリ全体で共有する少数のマスター鍵を保存し、それ以外のデータ暗号化にはアプリ内部で生成した一時鍵やデータ鍵を使用する。そして、それらのデータ鍵をマスター鍵で暗号化(ラップ)することで、安全性とスケーラビリティを両立する。この方式であれば、Keystore内の鍵数はほぼ一定に保たれ、上限に達する心配がない。
通常の実装では問題になりにくいものの、鍵の使い方次第では上限に到達する可能性があるため、aliasの再利用や鍵管理の設計を見直すことが重要である。特に、キーを都度生成するような実装になっていないかを確認することが、今後のAndroid対応において重要なポイントとなる。
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 に従いサンプルコードを選択すること。
図 5.7.1 生体認証を利用するサンプルコード選択フローチャート
Android 9.0(API Level 28)がリリースされた時点ではまだBiometricPromptのサポートライブラリの提供が無く、したがって利用できるのはAndroid 9.0搭載端末に限定されていた。しかし現在では サポートライブラリ [androidx.biometric] (https://developer.android.com/reference/androidx/biometric/package-summary) が提供されており、これによってAndroid 6.0以降のより広範な機種でBiometricPromptを利用することができるようになっている。以下に示すサンプルコードではサポートライブラリとして提供されているBiometricPromptを使用することとする。
5.7.1.1. 鍵と紐づいた認証を行う
以下に利用者の認証情報に紐づけた鍵を使う場合に生体認証機能をアプリから利用するためのサンプルコードを示す。
ポイント:
USE_FINGERPRINT(Android 6.0~Android 8.1)または USE_BIOMETRIC(Android 9.0~)パーミッションを利用宣言する [38]
"AndroidKeyStore" Provider からインスタンスを取得する
鍵を生成するためには生体情報の登録が必要である旨をユーザーに伝える
鍵生成(登録)時、暗号アルゴリズムは脆弱でないもの(基準を満たすもの)を使用する
鍵生成(登録)時、ユーザー(生体)認証の要求を有効にする(認証の有効期限は設定しない)
鍵を作る時点と鍵を使う時点で生体情報の登録状況が変わることを前提に設計を行う
暗号化するデータは、生体認証以外の手段で復元(代替)可能なものに限る
サンプルコードで使用しているサポートライブラリ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-2026 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-2026 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"
>
<!-- ★ポイント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"
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.7.1.2. 利用者の認証のみ行う
以下に、利用者による認証のみ行う場合に生体認証機能を利用するためのサンプルコードを示す。この場合、特にセキュリティ上注意すべき点はないが、参考のためサンプルコードを以下に掲載する。
MainActivity.java
/*
* Copyright (C) 2012-2026 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-2026 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"
>
<!-- ★ポイント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"
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.7.2. ルールブック
暗号鍵と紐づいた認証を行う際に生体機能を利用する場合には以下のルールを守ること。それ以外の用途で生体認証機能を利用する場合は、特にルールはない。
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());
5.7.3.2. Android 11における生体認証の変更点
Android 11では生体認証において以下の3点が変更となっている。
BiometricManager.Authenticatorsインターフェースの導入
BiometricPromptにおけるデータアクセスの強化
サポート終了のメソッド
BiometricManager.Authenticatorsインターフェース
BiometricManagerクラスおよびBiometricPromptクラスでサポートされる認証のタイプはBiometricManager.Authenticatorsインターフェースで以下のように定義されている。
認証タイプ |
解説 |
BIOMETRIC_STRONG |
強度レベルが Strong となるハードウェア要素を使用する認証 |
BIOMETRIC_WEAK |
強度レベルが Weak となるハードウェア要素を使用する認証 |
DEVICE_CREDENTIAL |
画面ロックの認証情報(ユーザーの PIN、パターン、パスワード)を使用する認証 |
アプリがどの認証タイプを受け入れるかは、setAllowedAuthenticators()メソッドに上記の認証タイプを引数として渡し定義する。渡せる認証タイプは単一でも複数でも可能となっており、たとえば強度レベルがStrongのハードウェア要素と画面ロックの認証情報を受け入れるよう定義するには、BIOMETRIC_STRONG | DEVICE_CREDENTIALを引数として渡す。
アプリが必要とする認証要素が使用可能かどうかを確認するにはcanAuthenticate()メソッドにより行う。このときPIN、パターン、パスワードをユーザーが作成していない場合、ACTION_BIOMETRIC_ENROLLインテントのアクションを呼び出す。このインテントは、アプリが受け入れる認証システムの認証情報を登録するようにユーザーに求める。
ユーザー認証の後、getAuthenticationType()メソッドを実行することで、認証されたユーザーがデバイスの認証情報と生体認証情報のどちらを使用したかを確認することができる。
BiometricPromptにおけるデータアクセスの強化
Android 11では、BiometricPromptにおけるデータアクセスが強化され、支払情報や医療情報など、秘匿性が高く鍵で保護されたユーザーデータにアプリがアクセスするたびに、その都度、生体認証情報もしくはデバイスの認証情報を求める必要がある。
既存の鍵とBiometricPromptオブジェクトを紐づける際のコードを以下に示す。
KeyGenParameterSpec authPerOpKeyGenParameterSpec =
new KeyGenParameterSpec.Builder(KEY_NAME, KeyProperties.PURPOSE_ENCRYPT)
// 生体認証情報もしくはデバイスの認証情報を受け入れる
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG | KeyProperties.AUTH_DEVICE_CREDENTIAL)
.build();
サポート終了のメソッド
Android 11では、以下のメソッドのサポートが終了し利用できない。
setDeviceCredentialAllowed() メソッド
setUserAuthenticationValidityDurationSeconds() メソッド
オーバーロードで定義された引数のないcanAuthenticate()メソッド