4. 安全にテクノロジーを活用する

Androidで言えばActivityやSQLiteなど、テクノロジーごとにセキュリティ観点の癖というものがある。そうしたセキュリティの癖を知らずに設計、コーディングしていると思わぬ脆弱性をつくりこんでしまうことがある。この章では開発者がAndroidのテクノロジーを活用するシーンを想定した記事を扱う。

4.1. Activityを作る・利用する

4.1.1. サンプルコード

Activityがどのように利用されるかによって、Activityが抱えるリスクや適切な防御手段が異なる。ここでは、Activityがどのように利用されるかという観点で、Activityを4つのタイプに分類した。次の判定フローによって作成するActivityがどのタイプであるかを判断できる。なお、どのような相手を利用するかによって適切な防御手段が決まるため、Activityの利用側の実装についても合わせて説明する。

_images/image33.png

図 4.1.1 Activity タイプ選択フロー

4.1.1.1. 非公開Activityを作る・利用する

非公開Activityは、同一アプリ内でのみ利用されるActivityであり、もっとも安全性の高いActivityである。

同一アプリ内だけで利用されるActivity(非公開Activity)を利用する際は、クラスを指定する明示的Intentを使えば誤って外部アプリにIntentを送信してしまうことがない。ただし、Activityを呼び出す際に使用するIntentは第三者によって読み取られる恐れがある。そのため、Activityに送信するIntentにセンシティブな情報を格納する場合には、その情報が悪意のある第三者に読み取られることのないように、適切な対応を実施する必要がある。

以下に非公開Activityを作る側のサンプルコードを示す。

ポイント(Activityを作る):

  1. taskAffinityを指定しない
  2. launchModeを指定しない
  3. exported="false"により、明示的に非公開設定する
  4. 同一アプリからのIntentであっても、受信Intentの安全性を確認する
  5. 利用元アプリは同一アプリであるから、センシティブな情報を返送してよい

Activityを非公開設定するには、AndroidManifest.xmlのactivity要素のexported属性をfalseと指定する。

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.activity.privateactivity" >

  <application
      android:allowBackup="false"
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name" >
    
    <!-- 非公開Activity -->
    <!-- ★ポイント1★ taskAffinityを指定しない -->
    <!-- ★ポイント2★ launchModeを指定しない -->
    <!-- ★ポイント3★ exported="false"により、明示的に非公開設定する -->
    <activity
        android:name=".PrivateActivity"
        android:label="@string/app_name"
        android:exported="false" />
    
    <!-- ランチャーから起動する公開Activity -->
    <activity
        android:name=".PrivateUserActivity"
        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>
PrivateActivity.java
/*
 * Copyright (C) 2012-2021 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.activity.privateactivity;

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

public class PrivateActivity extends Activity {
        
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.private_activity);

        // ★ポイント4★ 同一アプリからのIntentであっても、受信Intentの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        String param = getIntent().getStringExtra("PARAM");
        Toast.makeText(this, String.format("パラメータ「%s」を受け取った。", param),
                       Toast.LENGTH_LONG).show();
    }

    public void onReturnResultClick(View view) {
                
        // ★ポイント5★ 利用元アプリは同一アプリであるから、センシティブな情報を返送してよい
        Intent intent = new Intent();
        intent.putExtra("RESULT", "センシティブな情報");
        setResult(RESULT_OK, intent);
        finish();
    }
}

次に非公開Activityを利用する側のサンプルコードを示す。

ポイント(Activityを利用する):

  1. Activityに送信するIntentには、フラグFLAG_ACTIVITY_NEW_TASKを設定しない
  2. 同一アプリ内Activityはクラス指定の明示的Intentで呼び出す
  3. 利用先アプリは同一アプリであるから、センシティブな情報をputExtra()を使う場合に限り送信してもよい [1]
  4. 同一アプリ内Activityからの結果情報であっても、受信データの安全性を確認する
[1]ただし、ポイント1, 2, 6を遵守している場合を除いてはIntentが第三者に読み取られるおそれがあることに注意する必要がある。詳細はルールブックセクションの 4.1.2.2.4.1.2.3. を参照すること。
PrivateUserActivity.java
/*
 * Copyright (C) 2012-2021 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.activity.privateactivity;

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

public class PrivateUserActivity extends Activity {

    private static final int REQUEST_CODE = 1;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.user_activity);
    }
    
    public void onUseActivityClick(View view) {
        
        // ★ポイント6★ Activityに送信するIntentには、フラグFLAG_ACTIVITY_NEW_TASKを設定しない
        // ★ポイント7★ 同一アプリ内Activityはクラス指定の明示的Intentで呼び出す
        Intent intent = new Intent(this, PrivateActivity.class);
        
        // ★ポイント8★ 利用先アプリは同一アプリであるから、センシティブな情報をputExtra()を使う場合に限り
        // 送信してもよい
        intent.putExtra("PARAM", "センシティブな情報");
        
        startActivityForResult(intent, REQUEST_CODE);
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        if (resultCode != RESULT_OK) return;
        
        switch (requestCode) {
        case REQUEST_CODE:
            String result = data.getStringExtra("RESULT");
                    
            // ★ポイント9★ 同一アプリ内Activityからの結果情報であっても、受信データの安全性を確認する
            // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
            Toast.makeText(this, String.format("結果「%s」を受け取った。", result),
                           Toast.LENGTH_LONG).show();
            break;
        }
    }
}

4.1.1.2. 公開Activityを作る・利用する

公開Activityは、不特定多数のアプリに利用されることを想定したActivityである。マルウェアが送信したIntentを受信することがあることに注意が必要である。また、公開Activityを利用する場合には、送信するIntentがマルウェアに受信される、あるいは読み取られることがあることに注意が必要である。

以下に公開Activityを作る側のサンプルコードを示す。

ポイント(Activityを作る):

  1. exported="true"により、明示的に公開設定する
  2. 受信Intentの安全性を確認する
  3. 結果を返す場合、センシティブな情報を含めない
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.activity.publicactivity" >


  <application
      android:allowBackup="false"
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name" >
    
    <!-- 公開Activity -->
    <!-- ★ポイント1★ exported="true"により、明示的に公開設定する -->
    <activity
        android:name=".PublicActivity"
        android:label="@string/app_name"
        android:exported="true" >
      
      <!-- Action指定による暗黙的Intentを受信するようにIntent Filterを定義 -->
      <intent-filter>
        <action android:name="org.jssec.android.activity.MY_ACTION" />
        <category android:name="android.intent.category.DEFAULT" />
      </intent-filter>
    </activity>
  </application>
</manifest>
PublicActivity.java
/*
 * Copyright (C) 2012-2021 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.activity.publicactivity;

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

public class PublicActivity extends Activity {

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        
        // ★ポイント2★ 受信Intentの安全性を確認する
        // 公開Activityであるため利用元アプリがマルウェアである可能性がある。
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        String param = getIntent().getStringExtra("PARAM");
        Toast.makeText(this,
                       String.format("パラメータ「%s」を受け取った。", param),
                       Toast.LENGTH_LONG).show();
    }

    public void onReturnResultClick(View view) {
                
        // ★ポイント3★ 結果を返す場合、センシティブな情報を含めない
        // 公開Activityであるため利用元アプリがマルウェアである可能性がある。
        // マルウェアに取得されても問題のない情報であれば結果として返してもよい。

        Intent intent = new Intent();
        intent.putExtra("RESULT", "センシティブではない情報");
        setResult(RESULT_OK, intent);
        finish();
    }
}

次に公開Activityを利用する側のサンプルコードを示す。

ポイント(Activityを利用する):

  1. センシティブな情報を送信してはならない
  2. 結果を受け取る場合、結果データの安全性を確認する
PublicUserActivity.java
/*
 * Copyright (C) 2012-2021 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.activity.publicuser;

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

public class PublicUserActivity extends Activity {

    private static final int REQUEST_CODE = 1;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }
    
    public void onUseActivityClick(View view) {
        
        try {
            // ★ポイント4★ センシティブな情報を送信してはならない
            Intent intent = new Intent("org.jssec.android.activity.MY_ACTION");
            intent.putExtra("PARAM", "センシティブではない情報");
            startActivityForResult(intent, REQUEST_CODE);
        } catch (ActivityNotFoundException e) {
            Toast.makeText(this, "利用先Activityが見つからない。", Toast.LENGTH_LONG).show();
        }
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        // ★ポイント5★ 結果を受け取る場合、結果データの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        if (resultCode != RESULT_OK) return;
        switch (requestCode) {
        case REQUEST_CODE:
            String result = data.getStringExtra("RESULT");
            Toast.makeText(this,
                           String.format("結果「%s」を受け取った。", result),
                           Toast.LENGTH_LONG).show();
            break;
        }
    }
}

4.1.1.3. パートナー限定Activityを作る・利用する

パートナー限定Activityは、特定のアプリだけから利用できるActivityである。パートナー企業のアプリと自社アプリが連携してシステムを構成し、パートナーアプリとの間で扱う情報や機能を守るために利用される。

Activityを呼び出す際に使用するIntentは第三者によって読み取られる恐れがある。そのため、Activityに送信するIntentにセンシティブな情報を格納する場合には、その情報が悪意のある第三者に読み取られることのないように、適切な対応を実施する必要がある。

以下にパートナー限定Activityを作る側のサンプルコードを示す。

ポイント(Activityを作る):

  1. taskAffinityを指定しない
  2. launchModeを指定しない
  3. Intent Filterを定義せず、exported="true"を明示的に設定する
  4. 利用元アプリの証明書がホワイトリストに登録されていることを確認する
  5. パートナーアプリからのIntentであっても、受信Intentの安全性を確認する
  6. パートナーアプリに開示してよい情報に限り返送してよい

ホワイトリストを用いたアプリの確認方法については、「4.1.3.2. 利用元アプリを確認する」を参照すること。また、ホワイトリストに指定する利用先アプリの証明書ハッシュ値の確認方法は「5.2.1.3. アプリの証明書のハッシュ値を確認する方法」を参照すること。

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.activity.partneractivity" >

  <application
      android:allowBackup="false"
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name" >
    
    <!-- パートナー限定Activity -->
    <!-- ★ポイント1★ taskAffinityを指定しない -->
    <!-- ★ポイント2★ launchModeを指定しない -->
    <!-- ★ポイント3★ Intent Filterを定義せず、exported="true"を明示的に設定する -->
    <activity
        android:name=".PartnerActivity"
        android:exported="true" />
    
  </application>
</manifest>
PartnerActivity.java
/*
 * Copyright (C) 2012-2021 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.activity.partneractivity;

import org.jssec.android.shared.PkgCertWhitelists;
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 PartnerActivity extends Activity {
        
    // ★4.1.1.3 - ポイント4★ 利用元アプリの証明書がホワイトリストに登録されていることを確認する
    private static PkgCertWhitelists sWhitelists = null;
    private static void buildWhitelists(Context context) {
        boolean isdebug = Utils.isDebuggable(context);
        sWhitelists = new PkgCertWhitelists();
                
        // パートナーアプリ org.jssec.android.activity.partneruser の証明書ハッシュ値を登録
        sWhitelists.add("org.jssec.android.activity.partneruser", isdebug ?
            // debug.keystoreの"androiddebugkey"の証明書ハッシュ値
            "0EFB7236 328348A9 89718BAD DF57F544 D5CCB4AE B9DB34BC 1E29DD26 F77C8255" :
            // keystoreの"partner key"の証明書ハッシュ値
            "1F039BB5 7861C27A 3916C778 8E78CE00 690B3974 3EB8259F E2627B8D 4C0EC35A");
                
        // 以下同様に他のパートナーアプリを登録...
    }
    private static boolean checkPartner(Context context, String pkgname) {
        if (sWhitelists == null) buildWhitelists(context);
        return sWhitelists.test(context, pkgname);
    }
    
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        
        // ★4.1.1.3 - ポイント4★ 利用元アプリの証明書がホワイトリストに登録されていることを確認する
        if (!checkPartner(this, getCallingActivity().getPackageName())) {
            Toast.makeText(this, "利用元アプリはパートナーアプリではない。",
                            Toast.LENGTH_LONG).show();
            finish();
            return;
        }
        
        // ★4.1.1.3 - ポイント5★ パートナーアプリからのIntentであっても、受信Intentの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        Toast.makeText(this, "パートナーアプリからアクセスあり", Toast.LENGTH_LONG).show();
    }
    
    public void onReturnResultClick(View view) {

        // ★4.1.1.3 - ポイント6★ パートナーアプリに開示してよい情報に限り返送してよい
        Intent intent = new Intent();
        intent.putExtra("RESULT", "パートナーアプリに開示してよい情報");
        setResult(RESULT_OK, intent);
        finish();
    }
}
PkgCertWhitelists.java
/*
 * Copyright (C) 2012-2021 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-2021 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();
    }
}

次にパートナー限定Activityを利用する側のサンプルコードを示す。ここではパートナー限定Activityを呼び出す方法を説明する。

ポイント(Activityを利用する):

  1. 利用先パートナー限定Activityアプリの証明書がホワイトリストに登録されていることを確認する
  2. Activityに送信するIntentには、フラグFLAG_ACTIVITY_NEW_TASKを設定しない
  3. 利用先パートナー限定アプリに開示してよい情報はputExtra()を使う場合に限り送信してよい
  4. 明示的Intentによりパートナー限定Activityを呼び出す
  5. startActivityForResult()によりパートナー限定Activityを呼び出す
  6. パートナー限定アプリからの結果情報であっても、受信Intentの安全性を確認する

ホワイトリストを用いたアプリの確認方法については、「4.1.3.2. 利用元アプリを確認する」を参照すること。また、ホワイトリストに指定する利用先アプリの証明書ハッシュ値の確認方法は「5.2.1.3. アプリの証明書のハッシュ値を確認する方法」を参照すること。

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.activity.partneruser" >

  <queries>
    <package android:name="org.jssec.android.activity.partneractivity" />
  </queries>
  
  <application
      android:allowBackup="false"
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name" >
    
    <activity
        android:name=".PartnerUserActivity"
        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>
PartnerUserActivity.java
/*
 * Copyright (C) 2012-2021 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.activity.partneruser;

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

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

public class PartnerUserActivity extends Activity {

    // ★ポイント7★ 利用先パートナー限定Activityアプリの証明書がホワイトリストに登録されていることを確認する
    private static PkgCertWhitelists sWhitelists = null;
    private static void buildWhitelists(Context context) {
        boolean isdebug = Utils.isDebuggable(context);
        sWhitelists = new PkgCertWhitelists();
                
        // パートナー限定Activityアプリ org.jssec.android.activity.partneractivity の証明書ハッシュ値を登録
        sWhitelists.add("org.jssec.android.activity.partneractivity", isdebug ?
            // debug.keystoreの"androiddebugkey"の証明書ハッシュ値
            "0EFB7236 328348A9 89718BAD DF57F544 D5CCB4AE B9DB34BC 1E29DD26 F77C8255" :
            // keystoreの"my company key"の証明書ハッシュ値
            "D397D343 A5CBC10F 4EDDEB7C A10062DE 5690984F 1FB9E88B D7B3A7C2 42E142CA");
                
        // 以下同様に他のパートナー限定Activityアプリを登録...
    }
    private static boolean checkPartner(Context context, String pkgname) {
        if (sWhitelists == null) buildWhitelists(context);
        return sWhitelists.test(context, pkgname);
    }
        
    private static final int REQUEST_CODE = 1;

    // 利用先のパートナー限定Activityに関する情報
    private static final String TARGET_PACKAGE =
        "org.jssec.android.activity.partneractivity";
    private static final String TARGET_ACTIVITY =
        "org.jssec.android.activity.partneractivity.PartnerActivity";

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

        // ★ポイント7★ 利用先パートナー限定Activityアプリの証明書がホワイトリストに登録されていることを確認する
        if (!checkPartner(this, TARGET_PACKAGE)) {
            Toast.makeText(this,
                          "利用先 Activity アプリはホワイトリストに登録されていない。",
                           Toast.LENGTH_LONG).show();
            return;
        }
        
        try {
            Intent intent = new Intent();
                
            // ★ポイント8★ Activityに送信するIntentには、フラグFLAG_ACTIVITY_NEW_TASKを設定しない
                
            // ★ポイント9★ 利用先パートナー限定アプリに開示してよい情報をputExtra()を使う場合に限り送信してよい
            intent.putExtra("PARAM", "パートナーアプリに開示してよい情報");
                
            // ★ポイント10★ 明示的Intentによりパートナー限定Activityを呼び出す
            intent.setClassName(TARGET_PACKAGE, TARGET_ACTIVITY);
                
            // ★ポイント11★ startActivityForResult()によりパートナー限定Activityを呼び出す
            startActivityForResult(intent, REQUEST_CODE);
        }
        catch (ActivityNotFoundException e) {
            Toast.makeText(this,
                          "利用先Activityが見つからない。", Toast.LENGTH_LONG).show();
        }
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        if (resultCode != RESULT_OK) return;
        
        switch (requestCode) {
        case REQUEST_CODE:
            String result = data.getStringExtra("RESULT");
                        
            // ★ポイント12★ パートナー限定アプリからの結果情報であっても、受信Intentの安全性を確認する
            // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
            Toast.makeText(this,
                           String.format("結果「%s」を受け取った。", result),
                           Toast.LENGTH_LONG).show();
            break;
        }
    }
}
PkgCertWhitelists.java
/*
 * Copyright (C) 2012-2021 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-2021 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();
    }
}

4.1.1.4. 自社限定Activityを作る・利用する

自社限定Activityは、自社以外のアプリから利用されることを禁止するActivityである。複数の自社製アプリでシステムを構成し、自社アプリが扱う情報や機能を守るために利用される。

Activityを呼び出す際に使用するIntentは第三者によって読み取られる恐れがある。そのため、Activityに送信するIntentにセンシティブな情報を格納する場合には、その情報が悪意のある第三者に読み取られることのないように、適切な対応を実施する必要がある。

以下に自社限定Activityを作る側のサンプルコードを示す。

ポイント(Activityを作る):

  1. 独自定義Signature Permissionを定義する
  2. taskAffinityを指定しない
  3. launchModeを指定しない
  4. 独自定義Signature Permissionを要求宣言する
  5. Intent Filterを定義せず、exported="true"を明示的に設定する
  6. 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
  7. 自社アプリからのIntentであっても、受信Intentの安全性を確認する
  8. 利用元アプリは自社アプリであるから、センシティブな情報を返送してよい
  9. APKをExportするときに、利用元アプリと同じ開発者鍵でAPKを署名する
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.activity.inhouseactivity" >

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

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

    <!-- 自社限定Activity -->
    <!-- ★ポイント2★ taskAffinityを指定しない -->
    <!-- ★ポイント3★ launchModeを指定しない -->
    <!-- ★ポイント4★ 独自定義Signature Permissionを要求宣言する -->
    <!-- ★ポイント5★ Intent Filterを定義せず、exported="true"を明示的に設定する -->
    <activity
        android:name=".InhouseActivity"
        android:exported="true"
        android:permission="org.jssec.android.activity.inhouseactivity.MY_PERMISSION" >
    </activity>
  </application>

</manifest>
InhouseActivity.java
/*
 * Copyright (C) 2012-2021 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.activity.inhouseactivity;

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 InhouseActivity extends Activity {

    // 自社のSignature Permission
    private static final String MY_PERMISSION =
        "org.jssec.android.activity.inhouseactivity.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);

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

        // ★ポイント7★ 自社アプリからのIntentであっても、受信Intentの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照
        String param = getIntent().getStringExtra("PARAM");
        Toast.makeText(this, String.format("パラメータ「%s」を受け取った。", param),
                        Toast.LENGTH_LONG).show();
    }

    public void onReturnResultClick(View view) {

        // ★ポイント8★ 利用元アプリは自社アプリであるから、センシティブな情報を返送してよい
        Intent intent = new Intent();
        intent.putExtra("RESULT", "センシティブな情報");
        setResult(RESULT_OK, intent);
        finish();
    }
}
SigPerm.java
/*
 * Copyright (C) 2012-2021 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;
        }
    }
}
PkgCert.java
/*
 * Copyright (C) 2012-2021 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();
    }
}

★ポイント9★APKをExportするときに、利用元アプリと同じ開発者鍵でAPKを署名する。

_images/image34.png

図 4.1.2 利用元アプリと同じ開発者鍵で APK を署名する

次に自社限定Activityを利用する側のサンプルコードを示す。ここでは自社限定Activityを呼び出す方法を説明する。

ポイント(Activityを利用する):

  1. 独自定義Signature Permissionを利用宣言する
  2. 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
  3. 利用先アプリの証明書が自社の証明書であることを確認する
  4. 利用先アプリは自社アプリであるから、センシティブな情報をputExtra()を使う場合に限り送信してもよい
  5. 明示的Intentにより自社限定Activityを呼び出す
  6. 自社アプリからの結果情報であっても、受信Intentの安全性を確認する
  7. APKをExportするときに、利用先アプリと同じ開発者鍵でAPKを署名する
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.activity.inhouseuser" >

  <queries>
    <package android:name="org.jssec.android.activity.inhouseactivity" />
  </queries>
  
  <!-- ★ポイント10★ 独自定義Signature Permissionを利用宣言する -->
  <uses-permission
      android:name="org.jssec.android.activity.inhouseactivity.MY_PERMISSION" />

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

    <activity
        android:name="org.jssec.android.activity.inhouseuser.InhouseUserActivity"
        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>
InhouseUserActivity.java
/*
 * Copyright (C) 2012-2021 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.activity.inhouseuser;

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.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Toast;

public class InhouseUserActivity extends Activity {

    // 利用先のActivity情報
    private static final String TARGET_PACKAGE =
        "org.jssec.android.activity.inhouseactivity";
    private static final String TARGET_ACTIVITY =
        "org.jssec.android.activity.inhouseactivity.InhouseActivity";

    // 自社のSignature Permission
    private static final String MY_PERMISSION =
        "org.jssec.android.activity.inhouseactivity.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 static final int REQUEST_CODE = 1;

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

    public void onUseActivityClick(View view) {

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

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

        try {
            Intent intent = new Intent();

            // ★ポイント13★ 利用先アプリは自社アプリであるから、センシティブな情報をputExtra()を使う場合に限り送信してもよい
            intent.putExtra("PARAM", "センシティブな情報");

            // ★ポイント14★ 明示的Intentにより自社限定Activityを呼び出す
            intent.setClassName(TARGET_PACKAGE, TARGET_ACTIVITY);
            startActivityForResult(intent, REQUEST_CODE);
        }
        catch (ActivityNotFoundException e) {
            Toast.makeText(this, "利用先Activityが見つからない。", Toast.LENGTH_LONG).show();
        }
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        if (resultCode != RESULT_OK) return;

        switch (requestCode) {
        case REQUEST_CODE:
            String result = data.getStringExtra("RESULT");

            // ★ポイント15★ 自社アプリからの結果情報であっても、受信Intentの安全性を確認する
            // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
            Toast.makeText(this, String.format("結果「%s」を受け取った。", result),
                            Toast.LENGTH_LONG).show();
            break;
        }
    }
}
SigPerm.java
/*
 * Copyright (C) 2012-2021 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;
        }
    }
}
PkgCert.java
/*
 * Copyright (C) 2012-2021 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();
    }
}

★ポイント16★APKをExportするときに、利用先アプリと同じ開発者鍵でAPKを署名する。

_images/image34.png

図 4.1.3 利用先アプリと同じ開発者鍵で APK を署名する

4.1.2. ルールブック

Activityを作る際、またはActivityにIntentを送信する際には以下のルールを守ること。

  1. アプリ内でのみ使用するActivityは非公開設定する (必須)
  2. taskAffinityを指定しない (必須)
  3. launchModeを指定しない (必須)
  4. Activityに送信するIntentにはFLAG_ACTIVITY_NEW_TASKを設定しない (必須)
  5. 受信Intentの安全性を確認する (必須)
  6. 独自定義Signature Permissionは、自社アプリが定義したことを確認して利用する (必須)
  7. 結果情報を返す場合には、返送先アプリからの結果情報漏洩に注意する (必須)
  8. 利用先Activityが固定できる場合は明示的IntentでActivityを利用する (必須)
  9. 利用先Activityからの戻りIntentの安全性を確認する (必須)
  10. 他社の特定アプリと連携する場合は利用先Activityを確認する (必須)
  11. 資産を二次的に提供する場合には、その資産の従来の保護水準を維持する (必須)
  12. センシティブな情報はできる限り送らない (推奨)

4.1.2.1. アプリ内でのみ使用するActivityは非公開設定する (必須)

同一アプリ内からのみ利用されるActivityは他のアプリからIntentを受け取る必要がない。またこのようなActivityでは開発者もActivityを攻撃するIntentを想定しないことが多い。このようなActivityは明示的に非公開設定し、非公開Activityとする。

AndroidManifest.xml
    <!-- 非公開Activity -->
    <!-- ★4.1.1.1 - ポイント3★ exported="false"により、明示的に非公開設定する -->
    <activity
        android:name=".PrivateActivity"
        android:label="@string/app_name"
        android:exported="false" />

同一アプリ内からのみ利用されるActivityではIntent Filterを設置するような設計はしてはならない。Intent Filterの性質上、同一アプリ内の非公開Activityを呼び出すつもりでも、Intent Filter経由で呼び出したときに意図せず他アプリのActivityを呼び出してしまう可能性もある。詳細は、アドバンスト「4.1.3.1. exported 設定とintent-filter設定の組み合わせ(Activityの場合)」を参照すること。

AndroidManifest.xml(非推奨)
    <!-- 非公開Activity -->
    <!-- ★4.1.1.1 - ポイント3★ exported="false"により、明示的に非公開設定する -->
    <activity
        android:name=".PictureActivity"
        android:label="@string/picture_name"
        android:exported="false" >
        <intent-filter>
            <action android:name=”org.jssec.android.activity.OPEN />
        </intent-filter>
    </activity>

4.1.2.2. taskAffinityを指定しない (必須)

Androidでは、Activityはタスクによって管理される。タスクの名前は、ルートActivityの持つアフィニティによって決定される。一方でルート以外のActivityに関しては、所属するタスクがアフィニティだけでは決定されず、Activityの起動モードにも依存する。詳細は「4.1.3.4. ルートActivityについて」を参照すること。

デフォルト設定では各Activityはパッケージ名をアフィニティとして持つ。その結果、タスクはアプリごとに割り当てられるので、同一アプリ内の全てのActivityは同一タスクに所属する。タスクの割り当てを変更するには、AndroidManifest.xmlへの明示的なアフィニティ記述や、Activityに送信するIntentへのフラグ設定をすればよい。ただし、タスクの割り当てを変更した場合は、異なるタスクに属するActivityに送信したIntentを別アプリによって読み出せる可能性がある。

センシティブな情報を含む送信Intentおよび受信Intentの内容を読み取られないようにするには、AndroidManifest.xml内のapplication要素およびactivity要素でandroid:taskAffinityを指定せず、デフォルト(パッケージ名と同一)のままにすべきである。

以下に非公開Activityの作成側と利用側におけるAndroidManifest.xmlを示す。

AndroidManifest.xml
    <!-- ★4.1.1.1 - ポイント1★ taskAffinityを指定しない -->
    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" >

        <!-- ★4.1.1.1 - ポイント1★ taskAffinityを指定しない -->
        <activity
            android:name=".PrivateUserActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <!-- 非公開Activity -->
        <!-- ★4.1.1.1 - ポイント1★ taskAffinityを指定しない -->
        <activity
            android:name=".PrivateActivity"
            android:label="@string/app_name"
            android:exported="false" />
    </application>

タスクとアフィニティの詳細な解説は、「Google Android プログラミング入門」 [2]、あるいは、Google Developers API Guide "Tasks and Back Stack" [3] の解説および「4.1.3.3. Activityに送信されるIntentの読み取り(Android 5.0より前のバージョンについて)」、「4.1.3.4. ルートActivityについて」を参照すること。

[2]江川、藤井、麻野、藤田、山田、山岡、佐野、竹端著「Google Android プログラミング入門」 (アスキー・メディアワークス、2009年7月)
[3]https://developer.android.com/guide/components/tasks-and-back-stack.html

4.1.2.3. launchModeを指定しない (必須)

Activityの起動モードとは、Activityを呼び出す際に、Activityのインスタンスの新規生成や、タスクの新規生成を制御するための設定である。デフォルト設定は"standard"である。"standard"設定では、Intentを使ってActivityを呼び出すときには常に新規インスタンスを生成し、タスクは呼び出し側Activityが属するタスクに従い、新規にタスクが生成されることはない。タスクが新規に生成されると、呼び出しに使ったIntentが別のアプリから読み取り可能になる。そのため、センシティブな情報をIntentに含む場合には、Activityの起動モードには"standard"を用いるべきである。

Activityの起動モードはAndroidManifest.xml内にてandroid:launchModeで明示的に設定可能であるが、上記の理由により、各Activityに対してandroid:launchModeを指定せず、値をデフォルトのまま"standard"とするべきである。

AndroidManifest.xml
        <!-- 非公開Activity -->
        <!-- ★4.1.1.1 - ポイント2★ launchModeを指定しない -->
        <!-- ActivityにはlaunchModeを指定せず、値をデフォルトのまま”standard”とする -->
        <activity
            android:name=".PrivateUserActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <!-- 非公開Activity -->
        <!-- ★4.1.1.1 - ポイント2★ launchModeを指定しない -->
        <!-- ActivityにはlaunchModeを指定せず、値をデフォルトのまま”standard”とする -->
        <activity
            android:name=".PrivateActivity"
            android:label="@string/app_name"
            android:exported="false" />
    </application>

4.1.3.3. Activityに送信されるIntentの読み取り(Android 5.0より前のバージョンについて)」、「4.1.3.4. ルートActivityについて」を参照すること。

4.1.2.4. Activityに送信するIntentにはFLAG_ACTIVITY_NEW_TASKを設定しない (必須)

Activityの起動モードはstartActivity()あるいはstartActivityForResult()の実行時にも変更することが可能であり、タスクが新規に生成される場合がある。そのため、Activityの起動モードを実行時に変更しないようにする必要がある。

Activityの起動モードを変更するには、setFlags()やaddFlags()を用いてIntentにフラグを設定し、そのIntentをstartActivity()またはstartActivityForResult()の引数とする。タスクを新規に生成するためのフラグはFLAG_ACTIVITY_NEW_TASKである。FLAG_ACTIVITY_NEW_TASKが設定されると、呼び出されたActivityのタスクがバックグラウンドあるいはフォアグラウンド上に存在しない場合に、新規にタスクが生成される。FLAG_ACTIVITY_MULTIPLE_TASK はFLAG_ACTIVITY_NEW_TASKと同時に設定することもできる。この場合には、タスクが必ず新規生成される。どちらの設定もタスクを生成する可能性があるため、センシティブな情報を扱うIntentには設定しないようにすべきである。

Intentの送信例

        Intent intent = new Intent();

        // ★4.1.1.1 - ポイント6★ Activityに送信するIntentには、フラグFLAG_ACTIVITY_NEW_TASKを設定しない

        intent.setClass(this, PrivateActivity.class);
        intent.putExtra("PARAM", "センシティブな情報");

        startActivityForResult(intent, REQUEST_CODE);

なお、Activityに送信するIntentにFLAG_ACTIVITY_EXCLUDE_FROM_RECENTSフラグを明示的に設定することで、タスクが生成されたとしてもその内容が読み取られないようにできると考えるかもしれない。しかしながら、この方法を用いても送信されたIntentの内容を読み取ることが可能である。したがって、FLAG_ACTIVITY_NEW_TASKの使用は避けるべきである。

4.1.3.1. exported 設定とintent-filter設定の組み合わせ(Activityの場合)」および「4.1.3.3. Activityに送信されるIntentの読み取り(Android 5.0より前のバージョンについて)」、「4.1.3.4. ルートActivityについて」も参照すること。

4.1.2.5. 受信Intentの安全性を確認する (必須)

Activityのタイプによって若干リスクは異なるが、受信Intentのデータを処理する際には、まず受信Intentの安全性を確認しなければならない。

公開Activityは不特定多数のアプリからIntentを受け取るため、マルウェアの攻撃Intentを受け取る可能性がある。非公開Activityは他のアプリからIntentを直接受け取ることはない。しかし同一アプリ内の公開Activityが他のアプリから受け取ったIntentのデータを非公開Activityに転送することがあるため、受信Intentを無条件に安全であると考えてはならない。パートナー限定Activityや自社限定Activityはその中間のリスクであるため、やはり受信Intentの安全性を確認する必要がある。

3.2. 入力データの安全性を確認する」を参照すること。

4.1.2.6. 独自定義Signature Permissionは、自社アプリが定義したことを確認して利用する (必須)

自社アプリだけから利用できる自社限定Activityを作る場合、独自定義Signature Permissionにより保護しなければならない。AndroidManifest.xmlでのPermission定義、Permission要求宣言だけでは保護が不十分であるため、「5.2. PermissionとProtection Level」の「5.2.1.2. 独自定義のSignature Permissionで自社アプリ連携する方法」を参照すること。

4.1.2.7. 結果情報を返す場合には、返送先アプリからの結果情報漏洩に注意する (必須)

Activityのタイプによって、setResult()を用いて結果情報を返送する際の返送先アプリの信用度が異なる。公開Activityが結果情報を返送する場合、結果返送先アプリがマルウェアである可能性があり、結果情報が悪意を持って使われる危険性がある。非公開Activityや自社限定Activityの場合は、結果返送先は自社アプリであるため結果情報の扱いをあまり心配する必要はない。パートナー限定Activityの場合はその中間に位置する。

このようにActivityから結果情報を返す場合には、返送先アプリからの結果情報の漏洩に配慮しなければならない。

結果情報を返送する場合の例

    public void onReturnResultClick(View view) {

        // ★4.1.1.3 - ポイント6★ パートナーアプリに開示してよい情報に限り返送してよい
        Intent intent = new Intent();
        intent.putExtra("RESULT", "パートナーアプリに開示してよい情報");
        setResult(RESULT_OK, intent);
        finish();
    }

4.1.2.8. 利用先Activityが固定できる場合は明示的IntentでActivityを利用する (必須)

暗黙的IntentによりActivityを利用すると、最終的にどのActivityにIntentが送信されるかはAndroid OS任せになってしまう。もしマルウェアにIntentが送信されてしまうと情報漏洩が生じる。一方、明示的IntentによりActivityを利用すると、指定したActivity以外がIntentを受信することはなく比較的安全である。

処理を任せるアプリ(のActivity)をユーザーに選択させるなど、利用先Activityを実行時に決定したい場合を除けば、利用先Activityはあらかじめ特定できる。このようなActivityを利用する場合には明示的Intentを利用すべきである。

同一アプリ内のActivityを明示的Intentで利用する

        Intent intent = new Intent(this, PictureActivity.class);
        intent.putExtra("BARCODE", barcode);
        startActivity(intent);

他のアプリの公開Activityを明示的Intentで利用する

        Intent intent = new Intent();
        intent.setClassName(
            "org.jssec.android.activity.publicactivity",
            "org.jssec.android.activity.publicactivity.PublicActivity");
        startActivity(intent);

ただし他のアプリの公開Activityを明示的Intentで利用した場合も、相手先Activityを含むアプリがマルウェアである可能性がある。宛先をパッケージ名で限定したとしても、相手先アプリが実は本物アプリと同じパッケージ名を持つ偽物アプリである可能性があるからだ。このようなリスクを排除したい場合は、パートナー限定Activityや自社限定Activityの使用を検討する必要がある。

4.1.3.1. exported 設定とintent-filter設定の組み合わせ(Activityの場合)」も参照すること。

4.1.2.9. 利用先Activityからの戻りIntentの安全性を確認する (必須)

Activityのタイプによって若干リスクは異なるが、戻り値として受信したIntentのデータを処理する際には、まず受信Intentの安全性を確認しなければならない。

利用先Activityが公開Activityの場合、不特定のアプリから戻りIntentを受け取るため、マルウェアの攻撃Intentを受け取る可能性がある。利用先Activityが非公開Activityの場合、同一アプリ内から戻りIntentを受け取るのでリスクはないように考えがちだが、他のアプリから受け取ったIntentのデータを間接的に戻り値として転送することがあるため、受信Intentを無条件に安全であると考えてはならない。利用先Activityがパートナー限定Activityや自社限定Activityの場合、その中間のリスクであるため、やはり受信Intentの安全性を確認する必要がある。

3.2. 入力データの安全性を確認する」を参照すること。

4.1.2.10. 他社の特定アプリと連携する場合は利用先Activityを確認する (必須)

他社の特定アプリと連携する場合にはホワイトリストによる確認方法がある。自アプリ内に利用先アプリの証明書ハッシュを予め保持しておく。利用先の証明書ハッシュと保持している証明書ハッシュが一致するかを確認することで、なりすましアプリにIntentを発行することを防ぐことができる。具体的な実装方法についてはサンプルコードセクション「4.1.1.3. パートナー限定Activityを作る・利用する」を参照すること。また、技術的な詳細に関しては「4.1.3.2. 利用元アプリを確認する」を参照すること。

4.1.2.11. 資産を二次的に提供する場合には、その資産の従来の保護水準を維持する (必須)

Permissionにより保護されている情報資産および機能資産を他のアプリに二次的に提供する場合には、提供先アプリに対して同一のPermissionを要求するなどして、その保護水準を維持しなければならない。AndroidのPermissionセキュリティモデルでは、保護された資産に対するアプリからの直接アクセスについてのみ権限管理を行う。この仕様上の特性により、アプリに取得された資産がさらに他のアプリに、保護のために必要なPermissionを要求することなく提供される可能性がある。このことはPermissionを再委譲(Redelegation)していることと実質的に等価なので、Permissionの再委譲問題と呼ばれる。「5.2.3.4. Permissionの再委譲問題」を参照すること。

4.1.2.12. センシティブな情報はできる限り送らない (推奨)

不特定多数のアプリと連携する場合にはセンシティブな情報を送ってはならない。特定のアプリと連携する場合においても、意図しないアプリにIntentを発行してしまった場合や第三者によるIntentの盗聴などで情報が漏洩してしまうリスクがある。「4.1.3.5. Activity利用時のログ出力について」を参照すること。

センシティブな情報をActivityに送付する場合、その情報の漏洩リスクを検討しなければならない。公開Activityに送付した情報は必ず漏洩すると考えなければならない。またパートナー限定Activityや自社限定Activityに送付した情報もそれらActivityの実装に依存して情報漏洩リスクの大小がある。非公開Activityに送付する情報に至っても、Intentのdataに含めた情報はLogCat経由で漏洩するリスクがある。IntentのextrasはLogCatに出力されないので、センシティブな情報はextrasで送付するとよい。

センシティブな情報はできるだけ送付しないように工夫すべきである。送付する場合も、利用先Activityは信頼できるActivityに限定し、Intentの情報がLogCatへ漏洩しないように配慮しなければならない。

また、ルートActivityにはセンシティブな情報を送ってはならない。ルートActivityとは、タスクが生成された時に最初に呼び出されたActivityのことである。例えば、ランチャーから起動されたActivityは常にルートActivityである。

ルートActivityに関しての詳細は、「4.1.3.3. Activityに送信されるIntentの読み取り(Android 5.0より前のバージョンについて)」、「4.1.3.4. ルートActivityについて」も参照すること。

4.1.3. アドバンスト

4.1.3.1. exported 設定とintent-filter設定の組み合わせ(Activityの場合)

このガイド文書では、Activityの用途から非公開Activity、公開Activity、パートナー限定Activity、自社限定Activityの4タイプのActivityについて実装方法を述べている。各タイプに許されているAndroidManifest.xmlのexported属性とintent-filter要素の組み合わせを次の表にまとめた。作ろうとしているActivityのタイプとexported属性およびintent-filter要素の対応が正しいことを確認すること。

表 4.1.1 exported 属性と intent-filter 要素の組み合わせ
  exported属性の値
true false 無指定
intent-filter定義がある 公開 (使用禁止) (使用禁止)
intent-filter定義がない 公開、パートナー限定、自社限定 非公開 (使用禁止)

Activityのexported属性が無指定である場合にそのActivityが公開されるか非公開となるかは、intent-filterの定義の有無により決まるが [4]、本ガイドではActivityのexported属性を「無指定」にすることを禁止している。前述のようなAPIのデフォルトの挙動に頼る実装をすることは避けるべきであり、exported属性のようなセキュリティ上重要な設定を明示的に有効化する手段があるのであればそれを利用すべきであると考えられるためである。

[4]intent-filterが定義されていれば公開Activity、定義されていなければ非公開Activityとなる。 https://developer.android.com/guide/topics/manifest/activity-element.html#exported を参照のこと。

exported属性の値で「intent-filter定義がある」&「exported="false"」を使用禁止にしているのは、Androidの振る舞いに抜け穴があり、Intent Filterの性質上、意図せず他アプリのActivityを呼び出してしまう場合が存在するためである。以下の2つの図は、その説明のためのものである。図 4.1.4 は、同一アプリ内からしか非公開Activity(アプリA)を暗黙的Intentで呼び出せない正常な動作の例である。Intent-filter(図中action="X")を定義しているのが、アプリAしかいないので意図通りの動きとなっている。

_images/image35.png

図 4.1.4 正常な動作の例

図 4.1.5 は、アプリAに加えてアプリBでも同じintent-filter(図中action="X")を定義している場合である。図 4.1.5 では、アプリAが暗黙的Intentを送信して同一アプリ内の非公開Activityを呼び出そうとするが、「アプリケーションを選択」ダイアログが表示され、ユーザーの選択によって公開Activity(B-1)が呼び出されてしまう例を示している [5]。これにより他アプリに対してセンシティブな情報を送信したり、意図せぬ戻り値を受け取る可能性が生じてしまう。

_images/image36.png

図 4.1.5 意図しない動作の例

このように、Intent Filterを用いた非公開Activityの暗黙的Intent呼び出しは、意図せぬアプリとの情報のやり取りを許してしまうので行ってはならない。なお、この挙動はアプリA、アプリBのインストール順序には依存しないことを確認している。

[5]Android 8.0(API Level 26)以降の端末では 「アプリケーションを選択」ダイアログは表示されず、 図中アプリBの公開Activity(B-1)に自動遷移することが確認されている。 このことからも、Intent Filterを持つ非公開Activityを暗黙的Intentによって起動するのは禁止すべきである。

また、Android 12をターゲットとする場合、アプリにintent-filterを使用するActivity、Service、Broadcast Receiverが含まれている場合は、exported属性の明示的な宣言は必須となっており、省略した場合はビルド自体が不可となっている。

この場合、マニフェストファイルに警告が表示され、またビルド時にはエラーメッセージが表示される [6]

  • マニフェストファイルの警告メッセージ

When using intent filters, please specify android:exported as well

  • ビルド時のエラーメッセージ

Manifest merger failed : android:exported needs to be explicitly specified for <activity>. Apps targeting Android 12 and higher are required to specify an explicit value for `android:exported` when the corresponding component has an intent filter defined. See https://developer.android.com/guide/topics/manifest/activity-element#exported for details.

[6]Android Studio Arctic Fox 2020.3.1で確認

4.1.3.2. 利用元アプリを確認する

ここではパートナー限定Activityの実装に関する技術情報を解説する。パートナー限定Activityはホワイトリストに登録された特定のアプリからのアクセスを許可し、それ以外のアプリからはアクセスを拒否するActivityである。自社以外のアプリもアクセス許可対象となるため、Signature Permissionによる防御手法は利用できない。

基本的な考え方は、パートナー限定Activityの利用元アプリの身元を確認し、ホワイトリストに登録されたアプリであればサービスを提供する、登録されていないアプリであればサービスを提供しないというものである。利用元アプリの身元確認は、利用元アプリが持つ証明書を取得し、その証明書のハッシュ値をホワイトリストのハッシュ値と比較することで行う。

ここでわざわざ利用元アプリの「証明書」を取得せずとも、利用元アプリの「パッケージ名」との比較で十分ではないか?と疑問を持たれた方もいるかと思う。しかしパッケージ名は任意に指定できるため他のアプリへの成りすましが簡単である。成りすまし可能なパラメータは身元確認用には使えない。一方、アプリの持つ証明書であれば身元確認に使うことができる。証明書に対応する署名用の開発者鍵は本物のアプリ開発者しか持っていないため、第三者が同じ証明書を持ち、尚且つ署名検証が成功するアプリを作成することはできないからだ。ホワイトリストはアクセスを許可したいアプリの証明書データを丸ごと保持してもよいが、サンプルコードではホワイトリストのデータサイズを小さくするために証明書データのSHA-256ハッシュ値を保持することにしている。

この方法には次の二つの制約条件がある。

  • 利用元アプリにおいてstartActivity()ではなくstartActivityForResult()を使用しなければならない
  • 利用元アプリにおいてActivity以外から呼び出すことはできない

2つ目の制約事項はいわば1つ目の制約事項の結果として課される制約であるので、厳密には1つの同じ制約と言える。この制約は呼び出し元アプリのパッケージ名を取得するActivity.getCallingPackage()の制約により生じている。Activity.getCallingPackage()はstartActivityForResult()で呼び出された場合にのみ利用元アプリのパッケージ名を返すが、残念ながらstartActivity()で呼び出された場合にはnullを返す仕様となっている。そのためここで紹介する方法は必ず利用元アプリが、たとえ戻り値が不要であったとしても、startActivityForResult()を使わなければならないという制約がある。さらにstartActivityForResult()はActivityクラスでしか使えないため、利用元はActivityに限定されるという制約もある。

PartnerActivity.java
/*
 * Copyright (C) 2012-2021 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.activity.partneractivity;

import org.jssec.android.shared.PkgCertWhitelists;
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 PartnerActivity extends Activity {
        
    // ★4.1.1.3 - ポイント4★ 利用元アプリの証明書がホワイトリストに登録されていることを確認する
    private static PkgCertWhitelists sWhitelists = null;
    private static void buildWhitelists(Context context) {
        boolean isdebug = Utils.isDebuggable(context);
        sWhitelists = new PkgCertWhitelists();
                
        // パートナーアプリ org.jssec.android.activity.partneruser の証明書ハッシュ値を登録
        sWhitelists.add("org.jssec.android.activity.partneruser", isdebug ?
            // debug.keystoreの"androiddebugkey"の証明書ハッシュ値
            "0EFB7236 328348A9 89718BAD DF57F544 D5CCB4AE B9DB34BC 1E29DD26 F77C8255" :
            // keystoreの"partner key"の証明書ハッシュ値
            "1F039BB5 7861C27A 3916C778 8E78CE00 690B3974 3EB8259F E2627B8D 4C0EC35A");
                
        // 以下同様に他のパートナーアプリを登録...
    }
    private static boolean checkPartner(Context context, String pkgname) {
        if (sWhitelists == null) buildWhitelists(context);
        return sWhitelists.test(context, pkgname);
    }
    
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        
        // ★4.1.1.3 - ポイント4★ 利用元アプリの証明書がホワイトリストに登録されていることを確認する
        if (!checkPartner(this, getCallingActivity().getPackageName())) {
            Toast.makeText(this, "利用元アプリはパートナーアプリではない。",
                            Toast.LENGTH_LONG).show();
            finish();
            return;
        }
        
        // ★4.1.1.3 - ポイント5★ パートナーアプリからのIntentであっても、受信Intentの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        Toast.makeText(this, "パートナーアプリからアクセスあり", Toast.LENGTH_LONG).show();
    }
    
    public void onReturnResultClick(View view) {

        // ★4.1.1.3 - ポイント6★ パートナーアプリに開示してよい情報に限り返送してよい
        Intent intent = new Intent();
        intent.putExtra("RESULT", "パートナーアプリに開示してよい情報");
        setResult(RESULT_OK, intent);
        finish();
    }
}
PkgCertWhitelists.java
/*
 * Copyright (C) 2012-2021 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-2021 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();
    }
}

4.1.3.3. Activityに送信されるIntentの読み取り(Android 5.0より前のバージョンについて)

Android 5.0(API Level 21)以降では、getRecentTasks()から取得できる情報が、自分自身のタスク情報およびホームアプリのような機密情報ではないものに限定された。しかし、Android 5.0より前のバージョンでは、自分自身のタスク以外の情報も読み取ることができる。以下にAndroid 5.0より前のバージョンで発生する本問題の内容を解説する。

タスクのルートActivityに送信されたIntentは、タスク履歴に追加される。ルートActivityとはタスクの起点となるActivityのことである。タスク履歴に追加されたIntentは、ActivityManagerクラスを使うことでどのアプリからも自由に読み出すことが可能である。

アプリからタスク履歴を参照するためのサンプルコードを以下に示す。タスク履歴を参照するためには、AndroidManifest.xmlにGET_TASKS Permissionの利用を指定する。

AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.intent.maliciousactivity" >

  <!-- GET_TASKS Permissionを指定する -->
  <uses-permission android:name="android.permission.GET_TASKS" />

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

        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
  </application>
</manifest>
MaliciousActivity.java
/*
 * Copyright (C) 2012-2021 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.intent.maliciousactivity;

import java.util.List;
import java.util.Set;

import android.app.Activity;
import android.app.ActivityManager;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;

public class MaliciousActivity extends Activity {

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

        // ActivityManagerを取得する
        ActivityManager activityManager =
            (ActivityManager) getSystemService(ACTIVITY_SERVICE);
        // タスクの履歴を最新100件取得する
        List<ActivityManager.RecentTaskInfo> list = activityManager
            .getRecentTasks(100, ActivityManager.RECENT_WITH_EXCLUDED);
        for (ActivityManager.RecentTaskInfo r : list) {
            // ルートActivityに送信されたIntentを取得し、Logに表示する
            Intent intent = r.baseIntent;
            Log.v("baseIntent", intent.toString());
            Log.v("  action:", intent.getAction());
            String target = intent.getDataString();
            if (target != null) {
                Log.v("  data:", intent.getDataString());
            }
            if (r.origActivity != null) {
                Log.v("  pkg:",
                     r.origActivity.getPackageName() + r.origActivity.getClassName());
            }
            Bundle extras = intent.getExtras();
            if (extras != null) {
                Set<String> keys = extras.keySet();
                for(String key : keys) {
                    Log.v("  extras:", key + "=" + extras.get(key).toString());
                }
            }
        }
    }
}

ActivityManagerクラスのgetRecentTasks()により、指定した件数のタスク履歴を取得することができる。各タスクの情報はActivityManager.RecentTaskInfoクラスのインスタンスに格納されるが、そのメンバー変数baseIntentには、タスクのルートActivityに送信されたIntentが格納されている。ルートActivityとはタスクが生成された時に呼び出されたActivityであるので、Activityを呼び出す際には、以下の条件をどちらも満たさないように注意しなければならない。

  • Activityが呼び出された際に、タスクが新規に生成される
  • 呼び出されたActivityがバックグラウンドあるいはフォアグラウンド上に既に存在するタスクのルートActivityである

4.1.3.4. ルートActivityについて

ルートActivityとはタスクの起点となるActivityのことである。タスクが生成された時に起動されたActivityのことである、と言ってもよい。例えば、デフォルト設定のActivityがランチャーから起動された場合、そのActivityはルートActivityとなる。Androidの仕様によると、ルートActivityに送信されるIntentの内容は任意のアプリによって読み取られる恐れがある。そこで、ルートActivityへセンシティブな情報を送信しないように対策を講じる必要がある。本ガイドでは、呼び出されたActivityがルートとなるのを防ぐために以下の3点をルールに掲げた。

  • taskAffinityを指定しない
  • launchModeを指定しない
  • Activityに送信するIntentにはFLAG_ACTIVITY_NEW_TASKを設定しない

以下、Activityがルートとなるのはどのような場合かを中心にルートActivityについて考察する。呼び出されたActivityがルートとなるための条件に関連するのは、以下に挙げる項目である。

  • 呼び出されるActivityの起動モード
  • 呼び出されるActivityのタスクとその起動状態

まず、「呼び出されるActivityの起動モード」について説明する。Activityの起動モードは、AndroidManifest.xmlにandroid:launchModeを記述することで設定できる。記述しない場合は"standard"とみなされる。また、起動モードはIntentに設定するフラグによっても変更可能である。FLAG_ACTIVITY_NEW_TASKフラグは、Activityを"singleTask"モードで起動させる。

指定可能な起動モードは次のとおりである。特に、ルートActivityとの関連に焦点を当てて説明する。

standard

このモードで呼び出されたActivityはルートとなることはなく、呼び出し側のタスクに所属する。また、呼び出しの度にActivityのインスタンスが生成される。

singleTop

"standard"と同様であるが、フォアグラウンドタスクの最前面に表示されているActivityを起動する場合には、インスタンスが生成されないという点が異なる。

singleTask

Activityはアフィニティの値に従って所属するタスクが決まる。Activityのアフィニティと一致するタスクがバックグラウンドあるいはフォアグラウンドに存在しない場合には、タスクがActivityのインスタンスとともに新規に生成される。存在する場合にはどちらも生成されない。前者では、起動されたActivityのインスタンスはルートとなる。

singleInstance

"singleTask"と同様であるが、次の点で異なる。新規に生成されたタスクには、ルートActivityのみが所属できる点である。したがって、このモードで起動されたActivityのインスタンスは常にルートである。ここで注意が必要なのは、呼び出されるActivityが持つアフィニティと同じ名前のタスクが既に存在している場合であっても、呼び出されるActivityとタスクに含まれるActivityのクラス名が異なる場合である。その場合は、新規にタスクが生成される。

以上より、"singleTask"または"singleInstance"で起動されたActivityはルートになる可能性があることが分かる。アプリの安全性を確保するためには、これらのモードで起動しないようにしなければならない。

次に、「呼び出されるActivityのタスクとその起動状態」について説明する。たとえ、Activityが"standard"モードで呼び出されたとしても、そのActivityが所属するタスクの状態によってルートActivityとなる場合がある。

例として、呼び出されるActivityのタスクが既にバックグラウンドで起動している場合を考える。問題となるのは、そのタスクのActivityインスタンスが"singleInstance"で起動している場合である。"standard"で呼び出されたActivityのアフィニティがタスクと同じだった時に、既存の"singleInstance"のActivityの制限により、新規タスクが生成される。ただし、それぞれのActivityのクラス名が同じ場合は、タスクは生成されず、インスタンスは既存のものが利用される。いずれにしろ、呼び出されたActivityはルートActivityになる。

以上のように、ルートActivityが呼び出される条件は実行時の状態に依存するなど複雑である。アプリ開発の際には、"standard"モードでActivityを呼び出すように工夫すべきである。

非公開Activityに送信されるIntentが他アプリから読み取られる例として、非公開Activityの呼び出し側Activityを"singleInstance"モードで起動する場合のサンプルコードを以下に示す。このサンプルコードでは、非公開Activityが"standard"モードで起動されるが、呼び出し側Activityの"singleInstance"モードの条件により、非公開Activityは新規タスクのルートActivityとなってしまう。この時、非公開Activityに送信されるセンシティブな情報はタスク履歴に記録されるため、任意のアプリから読み取り可能である。なお、呼び出し側Activity、非公開Activityともに同一のアフィニティを持つ。

AndroidManifest.xml(非推奨)
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.activity.singleinstanceactivity" >

  <application
      android:allowBackup="false"
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name" >
    
    <!-- ルートActivityの起動モードを”singleInstance”とする -->
    <!-- アフィニティは設定せず、アプリのパッケージ名とする -->
    <activity
        android:name=".PrivateUserActivity"
        android:label="@string/app_name"
        android:launchMode="singleInstance"
        android:exported="true" >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
    
    <!-- 非公開Activity -->
    <!-- 起動モードを”standard”とする -->
    <!-- アフィニティは設定せず、アプリのパッケージ名とする -->
    <activity
        android:name=".PrivateActivity"
        android:label="@string/app_name"
        android:exported="false" />
  </application>
</manifest>

非公開Activityは、受信したIntentに対して結果を返すのみである。

PrivateActivity.java
/*
 * Copyright (C) 2012-2021 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.activity.singleinstanceactivity;

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

public class PrivateActivity extends Activity {
        
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.private_activity);

        // 受信Intentの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        String param = getIntent().getStringExtra("PARAM");
        Toast.makeText(this,
                       String.format("パラメータ「%s」を受け取った。", param),
                       Toast.LENGTH_LONG).show();
    }

    public void onReturnResultClick(View view) {        
        Intent intent = new Intent();
        intent.putExtra("RESULT", "センシティブな情報");
        setResult(RESULT_OK, intent);
        finish();
    }
}

非公開Activityの呼び出し側では、Intentにフラグを設定せずに、"standard"モードで非公開Activityを起動している。

PrivateUserActivity.java
/*
 * Copyright (C) 2012-2021 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.activity.singleinstanceactivity;

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

public class PrivateUserActivity extends Activity {

    private static final int REQUEST_CODE = 1;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.user_activity);
    }
    
    public void onUseActivityClick(View view) {
        
        // 非公開Activityを"standard"モードで起動する
        Intent intent = new Intent();       
        intent.setClass(this, PrivateActivity.class);
        intent.putExtra("PARAM", "センシティブな情報");
        
        startActivityForResult(intent, REQUEST_CODE);
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        if (resultCode != RESULT_OK) return;
        
        switch (requestCode) {
        case REQUEST_CODE:
            String result = data.getStringExtra("RESULT");
                        
            // 受信データの安全性を確認する
            // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
            Toast.makeText(this,
                           String.format("結果「%s」を受け取った。", result),
                           Toast.LENGTH_LONG).show();
            break;
        }
    }
}

4.1.3.5. Activity利用時のログ出力について

Activityを利用する際にActivityManagerがIntentの内容をLogCatに出力する。以下の内容はLogCatに出力されるため、センシティブな情報が含まれないように注意すべきだ。

  • 利用先パッケージ名
  • 利用先クラス名
  • Intent#setData()で設定したURI

例えば、メール送信する場合、URIにメールアドレスを指定してIntentを発行するとメールアドレスがLogCatに出力されてしまう。Intent#putExtra()で設定した値はLogCatに出力されないため、Extrasに設定して送るようにした方が良い。

次のようにメール送信するとLogCatにメールアドレスが表示されてしまう

MainActivity.java
    // URIはLogCatに出力される
    Uri uri = Uri.parse("mailto:test@gmail.com");
    Intent intent = new Intent(Intent.ACTION_SENDTO, uri);
    startActivity(intent);

次のようにExtrasを使用するとLogCatにメールアドレスが表示されなくなる

MainActivity.java
    // Extraに設定した内容はLogCatに出力されない
    Uri uri = Uri.parse("mailto:");
    Intent intent = new Intent(Intent.ACTION_SENDTO, uri);
    intent.putExtra(Intent.EXTRA_EMAIL, new String[] {"test@gmail.com"});
    startActivity(intent);

ただし、ActivityManager#getRecentTasks() によって IntentのExtrasを他のアプリから直接読める場合があるので、注意すること。詳しくは「4.1.2.2. taskAffinityを指定しない (必須)」、「4.1.2.3. launchModeを指定しない (必須)」および「4.1.2.4. Activityに送信するIntentにはFLAG_ACTIVITY_NEW_TASKを設定しない (必須)」を参照のこと。

4.1.3.6. PreferenceActivityのFragment Injection対策について

PreferenceActivityを継承したクラスが公開Activityとなっている場合、Fragment Injection [7] と呼ばれる問題が発生する可能性がある。この問題を防ぐためには PreferenceActivity.IsValidFragment()をoverrideし、引数の値を適切にチェックすることでActivityが意図しないFragmentを扱わないようにする必要がある。(入力データの安全性については「3.2. 入力データの安全性を確認する」参照)

[7]Fragment Injectionの詳細は以下のURLを参照のこと https://securityintelligence.com/new-vulnerability-android-framework-fragment-injection/

以下に、IsValidFragment()をoverrideしたサンプルを示す。なお、ソースコードの難読化を行うと、クラス名が変わり、引数の値との比較結果が変わってまう可能性があるので、別途対応が必要になる。

overrideしたisValidFragment()メソッドの例

    protected boolean isValidFragment(String fragmentName) {
        // 難読化時の対応は別途行うこと
        return PreferenceFragmentA.class.getName().equals(fragmentName)
                || PreferenceFragmentB.class.getName().equals(fragmentName)
                || PreferenceFragmentC.class.getName().equals(fragmentName)
                || PreferenceFragmentD.class.getName().equals(fragmentName);
    }

なお、アプリのtargetSdkVersionが19以上である場合、PreferenceActivity.isValidFragment()をoverrideしないと、Fragmentが挿入された段階(isValidFragment()が呼ばれた段階)でセキュリティ例外が発生しアプリが終了するため、PreferenceActivity.isValidFragment()のoverrideが必須である。

4.1.3.7. Autofillフレームワークについて

AutofillフレームワークはAndroid 8.0(API Level 26)で追加された仕組みである。この仕組みを利用することで、ユーザー名、パスワード、住所、電話番号、クレジットカード情報等が入力されたときにそれらを保存しておき、再度必要になった時に取り出してアプリに自動入力(Autofill)する機能を実現することができる。ユーザーの入力負荷を軽減することができる便利な仕組みであるが、あるアプリが扱うパスワードやクレジットカード情報等のセンシティブな情報を他のアプリ(Autofill service)に渡すことを想定しており、取り扱いには十分に気をつける必要がある。

仕組み(概要)
2つのコンポーネント

以下に、Autofillフレームワークに登場する2つのコンポーネント [8] の概要を示す。

  • Autofillの対象となるアプリ(利用アプリ):
    • Viewの情報(テキストおよび属性)をAutofill serviceに渡したり、Autofill serviceからAutofillに必要な情報を提供されたりする。
    • Activityを持つすべてのアプリが利用アプリとなる(Foreground時)。
    • 利用アプリが持つすべてのViewがAutofillの対象になる可能性がある。View単位で明示的にAutofillの対象外とすることも可能。
    • Autofill機能の利用を同一パッケージ内のAutofill serviceに限定することも可能。
  • Autofill serviceを提供するサービス(Autofill service):
    • アプリから渡されたViewの情報を保存したり(ユーザーの許可が必要)、ViewにAutofillするための情報(候補リスト)をアプリに提供したりする。
    • 保存対象のViewはAutofill serviceが決定する(AutofillフレームワークはデフォルトでActivityに含まれるすべてのViewの情報をAutofill serviceに渡す)。
    • 3rd Party製のAutofill serviceも作成できる。
    • 端末内に複数存在することが可能でユーザーにより「設定」から選択されたServiceのみ有効になる(「なし」も選択可能)。
    • Serviceが、扱うユーザー情報を保護するためにパスワード入力等によってユーザー認証をするためのUIを持つことも可能。
[8]「利用アプリ」と「Autofill service」は、それぞれ同じパッケージ(APKファイル)であることも、別パッケージであることもあり得る。
Autofillフレームワークの処理フロー

図 4.1.6 はAutofill時のAutofill関連コンポーネント間の処理フローを示している。利用アプリのViewのフォーカス移動等を契機にAutoillフレームワークを介してViewの情報(主にViewの親子関係や個々の属性)が「設定」で選択されたAutofill serviceに渡る。Autofill serviceは渡された情報を元にAutofillに必要な情報(候補リスト)をDBから取り出し、フレームワークに返信する。フレームワークは候補リストをユーザーに提示し、ユーザーが選択したデータによりアプリでAutofillが行われる。

_images/image37.png

図 4.1.6 Autofill 時のコンポーネント間の処理フロー

一方、図 4.1.7 はAutofillによるユーザーデータ保存時の処理フローを示している。AutofillManager#commit()の呼び出しやActivityの終了を契機に、AutofillしたViewの値に変更があり、かつ、Autofillフレームワークが表示する保存許可ダイアログに対してユーザーが許可した場合、Viewの情報(テキスト含む)がAutofillフレームワークを介して「設定」で選択されたAutofill serviceに渡され、Autofill serviceがViewの情報をDBに保存して一連の処理が完了となる。

_images/image38.png

図 4.1.7 ユーザーデータ保存時のコンポーネント間の処理フロー

Autofill利用アプリにおけるセキュリティ上の懸案

「仕組み(概要)」の項で示した通りAutofillフレームワークにおけるセキュリティモデルでは、ユーザーが「設定」で安全なAutofill serviceを選択し、保存時にどのデータをどのAutofill serviceに渡してもよいか適切に判断できることを前提としている。

ところが、ユーザーがうっかり安全でないAutofill serviceを選択したり、Autofill serviceに渡すべきでないセンシティブな情報の保存を許可してしまったりする可能性がある。以下に、この場合に起きうる被害について考察する。

保存時、ユーザーがAutofill serviceを選択し、保存許可ダイアログに対して許可した場合、利用アプリで表示されているActivityに含まれるすべてのViewの情報がAutofill serviceに渡る可能性がある。ここで、Autofill serviceがマルウェアの場合や、Autofill serviceにViewの情報を外部ストレージや安全でないクラウドサービスに保存する等のセキュリティ上の問題があった場合には、利用アプリで扱う情報の漏洩につながってしまうリスクが考えられる。

一方、Autofill時、ユーザーがAutofill serviceとしてマルウェアを選択してしまっていた場合、マルウェアが送信した値を入力してしまう可能性がある。ここで、アプリやアプリのデータを送信した先のクラウドサービスが入力データの安全性を十分に確認していなかった場合、情報漏洩やアプリ/サービスの停止等につながってしまうリスクが考えられる。

なお、「2つのコンポーネント」で書いたように、Activityを持つアプリが自動的にAutofillの対象になるため、Activityを持つアプリのすべての開発者は上記のリスクを考慮して設計や実装を行う必要がある。以下に、上記のリスクに対する対策案を示すが、「3.1.3. 資産分類と保護施策」なども参考にして、アプリに必要な対策を検討した上で、適用することをお勧めする。

リスクに対する対策-1

前述のように、Autofillフレームワークでは基本的にユーザーの裁量によってセキュリティが担保されている。そのためアプリでできる対策は限られているが、Viewに対してimportantForAutofill 属性で"no"等を指定してAutofill serviceにViewの情報を渡さないようにする(Autofillの対象外とする)ことで、ユーザーが適切な選択や許可をできなかった場合(マルウェアをAutofill serviceとして利用するように選択する等)でも、上記の懸案を軽減することができる。[9]

[9]ユーザーが意図的にAutofill機能を利用した場合など、本対策でも上記の懸案を回避できないことがある。「リスクに対する対策-2」を実施することでこのような場合にも対応することができる。

importantForAutofill 属性は、以下のいずれかの方法によって指定することができる。

  • レイアウトXMLのimportantForAutofill属性を指定する
  • View#setImportantForAutofill()を呼び出す

以下に指定可能な値を示す。指定する範囲によって適切な値を使うこと。特に、"no"を指定した場合、指定したViewはAutofillの対象外になるが、子供はAutofillの対象になることに注意すること。デフォルト値は、"auto"となっている。

表 4.1.2 Autofill の対象になるか

定数名
指定したView 子供のView

"auto"

IMPORTANT_FOR_AUTOFILL_AUTO
自動 [10] 自動 [10]

"no"

IMPORTANT_FOR_AUTOFILL_NO
対象外 対象

"noExcludeDescendants"

IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
対象外 対象外

"yes"

IMPORTANT_FOR_AUTOFILL_YES
対象 対象

"yesExcludeDescendants"

IMPORTANT_FOR_AUTOFILL_YES_EXCLUDE_DESCENDANTS
対象 対象外
[10](1, 2) Autofillフレームワークが決定

また、AutofillManager#hasEnabledAutofillServices()を利用して、Autofill機能の利用を同一パッケージ内のAutofill serviceに限定することも可能である。

以下に、「設定」で同一パッケージ内のAutofill serviceを利用するように設定されている場合のみ、Activityの全てのViewをAutofillの対象にする(実際にAutofillの対象になるかはAutofill service次第)場合の例を示す。個別のViewに対してView#setImportantForAutofill()を呼び出すことも可能である。

DisableForOtherServiceActivity.java
/*
 * Copyright (C) 2012-2021 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.autofillframework.autofillapp;

import android.os.Bundle;
import android.service.autofill.UserData;
import android.app.Activity;
import android.util.Log;
import android.view.View;
import android.view.autofill.AutofillManager;
import android.widget.EditText;
import android.widget.TextView;

import org.jssec.android.autofillframework.R;

import java.util.Iterator;
import java.util.List;

public class DisableForOtherServiceActivity extends Activity {
    private String TAG = "AutofillSample";
    private boolean mIsAutofillEnabled = false;

    private EditText mUsernameEditText;
    private EditText mPasswordEditText;

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

        mUsernameEditText = (EditText)findViewById(R.id.field_username);
        mPasswordEditText = (EditText)findViewById(R.id.field_password);

        findViewById(R.id.button_login).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                login();
            }
        });

        findViewById(R.id.button_clear).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                resetFields();
            }
        });
        //このActivityではフローティングツールバーの対応をしていないため、
        // 「自動入力」の選択でAutofill機能が利用可能になります。
    }

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

    @Override
    protected void onResume() {
        super.onResume();
        updateAutofillStatus();
        View rootView = this.getWindow().getDecorView();
        if (!mIsAutofillEnabled) {
            //同一パッケージ内のAutofill serviceを利用しない場合は、全てのViewをAutofillの対象外にする
            rootView.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS);
        } else {
            //同一パッケージ内のAutofill serviceを利用する場合は、全てのViewをAutofillの対象にする
            //特定のViewに対してView#setImportantForAutofill()を呼び出すことも可能
            rootView.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_AUTO);
        }
        testFieldClassification();
    }
    private void login() {
        String username = mUsernameEditText.getText().toString();
        String password = mPasswordEditText.getText().toString();

        //Viewから取得したデータの安全性を確認する
        if (!Util.validateUsername(username) || !Util.validatePassword(password)) {
            //適切なエラー処理をする
        }

        //サーバーにusername, passwordを送信

        finish();
    }

    private void resetFields() {
        mUsernameEditText.setText("");
        mPasswordEditText.setText("");
    }

    private void updateAutofillStatus() {
        AutofillManager mgr = getSystemService(AutofillManager.class);

        mIsAutofillEnabled = mgr.hasEnabledAutofillServices();

        TextView statusView = (TextView) findViewById(R.id.label_autofill_status);
        String status = "自社のautofill serviceが--です";
        if (mIsAutofillEnabled) {
            status = "同一パッケージ内のautofill serviceが有効です";
        } else {
            status = "同一パッケージ内のautofill serviceが無効です";
        }
        statusView.setText(status);
    }
    private void testFieldClassification() {
        AutofillManager mgr = getSystemService(AutofillManager.class);
        List<String> algorithms = mgr.getAvailableFieldClassificationAlgorithms();
        Log.d(TAG, "algorithms num=" + algorithms.size());
        Iterator it = algorithms.iterator();
        while (it.hasNext()) {
            Log.d(TAG, "algorithms=" + (String)it.next());
        }
        String defaultAlgorithm = mgr.getDefaultFieldClassificationAlgorithm();
        Log.d(TAG, "default algorithms=" + defaultAlgorithm);

        boolean isFieldClassificationEnabled = mgr.isFieldClassificationEnabled();
        Log.d(TAG, "isFieldClassificationEnabled=" + isFieldClassificationEnabled);

        UserData userData = mgr.getUserData();
        if (userData != null) {
            Log.d(TAG, "userData fcAlgorithms=" +
                       userData.getFieldClassificationAlgorithm());
        } else {
            Log.d(TAG, "userData is null");
        }
    }
}
リスクに対する対策-2

アプリで「リスクに対する対策-1」を施した場合でも、ユーザーがViewの長押しでフローティングツールバーなどを表示させて「自動入力」を選択すると、強制的にAutofillを利用できてしまう。この場合、importantForAutofill属性で"no"等を指定したViewを含む全てのViewの情報がAutofill Serviceに渡ることになる。

リスクに対する対策-1」に加えて、フローティングツールバーなどのメニューから「自動入力」を削除することで、上記のような場合でも、情報漏えいのリスクを回避することができる。

以下にサンプルコードを示す。

DisableAutofillActivity.java
/*
 * Copyright (C) 2012-2021 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.autofillframework.autofillapp;

import android.os.Bundle;
import android.app.Activity;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import android.view.SubMenu;
import android.view.View;
import android.widget.EditText;

import org.jssec.android.autofillframework.R;

public class DisableAutofillActivity extends Activity {

    private EditText mUsernameEditText;
    private EditText mPasswordEditText;

    private ActionMode.Callback mActionModeCallback;

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

        mUsernameEditText = (EditText) findViewById(R.id.field_username);
        mPasswordEditText = (EditText) findViewById(R.id.field_password);

        findViewById(R.id.button_login).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                login();
            }
        });
        findViewById(R.id.button_clear).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                resetFields();
            }
        });
        mActionModeCallback = new ActionMode.Callback() {
            @Override
            public boolean onCreateActionMode(ActionMode mode, Menu menu) {
                removeAutofillFromMenu(menu);
                return true;
            }

            @Override
            public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
                removeAutofillFromMenu(menu);
                return true;
            }

            @Override
            public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
                    return false;
                }

            @Override
            public void onDestroyActionMode(ActionMode mode) {
            }
        };

        //フローティングツールバーから「自動入力」を削除する
        setMenu();
    }

    void setMenu() {
        if (mActionModeCallback == null) {
            return;
        }
        //Activityに含まれる全ての編集可能なTextViewについてセットすること
        mUsernameEditText.setCustomInsertionActionModeCallback(mActionModeCallback);
        mPasswordEditText.setCustomInsertionActionModeCallback(mActionModeCallback);
    }

    //Menuの階層を巡って「自動入力」を削除する
    void removeAutofillFromMenu(Menu menu) {
        if (menu.findItem(android.R.id.autofill) != null) {
            menu.removeItem(android.R.id.autofill);
        }

        for (int i=0; i<menu.size(); i++) {
            SubMenu submenu = menu.getItem(i).getSubMenu();
            if (submenu != null) {
                removeAutofillFromMenu(submenu);
            }
        }
    }

    private void login() {
        String username = mUsernameEditText.getText().toString();
        String password = mPasswordEditText.getText().toString();

        //Viewから取得したデータの安全性を確認する
        if (!Util.validateUsername(username) || Util.validatePassword(password)) {
            //適切なエラー処理をする
        }

        //サーバーにusername, passwordを送信

        finish();
    }

    private void resetFields() {
        mUsernameEditText.setText("");
        mPasswordEditText.setText("");
    }
}
リスクに対する対策-3

Android 9.0 (API Level 28)で、現在有効となっている Autofill Service のコンポーネントが何であるかを AutofillManager#getAutofillServiceComponentName() によって知ることができるようになった。これを用いてパッケージ名を取得し、自身のアプリが信頼を置いている Autofill Service かどうかを確認できる。

この場合、先に「4.1.3.2. 利用元アプリを確認する」で述べた通りパッケージ名は成りすましが可能なため、これのみで身元確認をするのは推奨できない。4.1.3.2. に掲載の例と同じく、パッケージ名から Autofill Service の証明書を取得し、それをあらかじめホワイトリストに登録されているものと一致するかどうかによって身元確認を行うべきである。この方法の詳細については 4.1.3.2. に詳しいのでそちらを参照されたい。

以下に、あらかじめホワイトリストに登録されている Autofill Service が有効になっている場合のみ、Activity の全ての View を Autofill の対象にする場合の例を示す。

EnableOnlyWhitelistedServiceActivity.java
/*
 * Copyright (C) 2012-2021 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.autofillframework.autofillapp;

import android.content.ComponentName;
import android.content.Context;
import android.os.Bundle;
import android.app.Activity;
import android.view.View;
import android.view.autofill.AutofillManager;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;

import org.jssec.android.shared.PkgCertWhitelists;
import org.jssec.android.autofillframework.R;

public class EnableOnlyWhitelistedServiceActivity extends Activity {
    private static PkgCertWhitelists sWhitelists = null;
    private static void buildWhitelists(Context context) {
        sWhitelists = new PkgCertWhitelists();
        // 信頼する Autofill Service の証明書ハッシュ値を登録
        sWhitelists.add("com.google.android.gms",
            "1975B2F17177BC89A5DFF31F9E64A6CAE281A53DC1D1D59B1D147FE1C82AFA00");
        // 以下同様に他の信頼する Autofill Service を登録...
    }
    private static boolean checkService(Context context, String pkgname) {
        if (sWhitelists == null) buildWhitelists(context);
        return sWhitelists.test(context, pkgname);
    }

    private boolean mIsAutofillEnabled = false;

    private EditText mUsernameEditText;
    private EditText mPasswordEditText;

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

        mUsernameEditText = (EditText)findViewById(R.id.field_username);
        mPasswordEditText = (EditText)findViewById(R.id.field_password);

        findViewById(R.id.button_login).setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    login();
                }
            });

        findViewById(R.id.button_clear).setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    resetFields();
                }
            });
        //このActivityではフローティングツールバーの対応をしていないため、
        // 「自動入力」の選択でAutofill機能が利用可能になります。
    }

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

    @Override
    protected void onResume() {
        super.onResume();
        updateAutofillStatus();
        View rootView = this.getWindow().getDecorView();
        if (!mIsAutofillEnabled) {
            // ホワイトリストに登録されている Autofill Service を利用しない場合は、
            // 全てのViewをAutofillの対象外にする
            rootView.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS);
        } else {
            // ホワイトリストに登録されている Autofill Service を利用する場合は、
            // 全てのViewをAutofillの対象にする
            // 特定のViewに対してView#setImportantForAutofill()を呼び出すことも可能
            rootView.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_AUTO);
        }
    }
    private void login() {
        String username = mUsernameEditText.getText().toString();
        String password = mPasswordEditText.getText().toString();

        //Viewから取得したデータの安全性を確認する
        if (!Util.validateUsername(username) || !Util.validatePassword(password)) {
            //適切なエラー処理をする
        }

        //サーバーにusername, passwordを送信

        finish();
    }

    private void resetFields() {
        mUsernameEditText.setText("");
        mPasswordEditText.setText("");
    }

    private void updateAutofillStatus() {
        AutofillManager mgr = getSystemService(AutofillManager.class);
        // Android 9.0 (API Level 28) 以降では Autofill Service のコンポーネント情報を取得できるようになった
        ComponentName componentName = mgr.getAutofillServiceComponentName();
        String componentNameString = "None";
        if (componentName == null) {
            mIsAutofillEnabled = false;//「設定」‐「自動入力サービス」が「なし」に設定されている
            Toast.makeText(this, "自動入力サービスなし", Toast.LENGTH_LONG).show();
        } else {
            String autofillServicePackage = componentName.getPackageName();
            // 使おうとしている Autofill Service がホワイトリストに登録されているかを検査する
            if (checkService(this, autofillServicePackage)) {
                mIsAutofillEnabled = true;
                Toast.makeText(this, "信頼している自動入力サービス: " + autofillServicePackage,
                                Toast.LENGTH_LONG).show();
            } else {
                Toast.makeText(this, "信頼できない自動入力サービス: " + autofillServicePackage,
                                Toast.LENGTH_LONG).show();
                mIsAutofillEnabled = false;    //それ以外の場合は Autofill Service を利用しない
            }
            componentNameString =
                autofillServicePackage + " / " + componentName.getClassName();
        }

        TextView statusView = (TextView) findViewById(R.id.label_autofill_status);
        String status = "current autofill service: \n" + componentNameString;
        statusView.setText(status);
    }
}
PkgCertWhitelists.java
/*
 * Copyright (C) 2012-2021 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-2021 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();
    }
}

4.2. Broadcastを受信する・送信する

4.2.1. サンプルコード

Broadcastを受信するにはBroadcast Receiverを作る必要がある。どのようなBroadcastを受信するかによって、Broadcast Receiverが抱えるリスクや適切な防御手段が異なる。次の判定フローによって作成するBroadcast Receiverがどのタイプであるかを判断できる。ちなみに、パートナー限定の連携に必要なBroadcast送信元アプリのパッケージ名を受信側アプリで確認する手段がないため、パートナー限定Broadcast Receiverを作る事はできない。

_images/image39.png

図 4.2.1 Broadcast Receiver のタイプ選択フロー

またBroadcast Receiverにはその定義方法により、静的Broadcast Receiverと動的Broadcast Receiverとの2種類があり、下表のような特徴の違いがある。サンプルコード中では両方の実装方法を紹介している。なお、どのような相手にBroadcastを送信するかによって送信する情報の適切な防御手段が決まるため、送信側の実装についても合わせて説明する。

表 4.2.1 Broadcast Receiver の定義方法と特徴
  定義方法 特徴
静的Broadcast Receiver AndroidManifest.xmlに<receiver>要素を記述することで定義する
  • システムから送信される一部のBroadcast(ACTION_BATTERY_CHANGEDなど)を受信できない制約がある。
  • アプリが初回起動してからアンインストールされるまでの間、Broadcastを受信できる。
  • アプリのtargetSDKVersionが26以上の場合、Android 8.0(API level 26)以降の端末では、暗黙的Broadcat Receiverを登録できない [11]
動的Broadcast Receiver プログラム中でregisterReceiver()およびunregisterReceiver()を呼び出すことにより、動的にBroadcast Receiverを登録/登録解除する
  • 静的Broadcast Receiverでは受信できないBroadcastでも受信できる。
  • Activityが前面に出ている期間だけBroadcastを受信したいなど、Broadcastを受信したいなど、Broadcastの受信可能期間をプログラムで制御できる。
  • 非公開のBroadcast Receiverを作ることはできない。
[11]システムが送信する暗黙的Broadcast Intentの中には例外的に利用可能なものも存在する。詳細は以下のURLを参照のこと https://developer.android.com/guide/components/broadcast-exceptions.html

4.2.1.1. 非公開Broadcast Receiver - Broadcastを受信する・送信する

非公開Broadcast Receiverは、同一アプリ内から送信されたBroadcastだけを受信できるBroadcast Receiverであり、もっとも安全性の高いBroadcast Receiverである。動的Broadcast Receiverを非公開で登録することはできないため、非公開Broadcast Receiverでは静的Broadcast Receiverだけで構成される。

ポイント(Broadcastを受信する):

  1. exported="false"により、明示的に非公開設定する
  2. 同一アプリ内から送信されたBroadcastであっても、受信Intentの安全性を確認する
  3. 結果を返す場合、送信元は同一アプリ内であるから、センシティブな情報を返送してよい
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.broadcast.privatereceiver" >


  <application
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name"
      android:allowBackup="false" >
    
    <!-- 非公開Broadcast Receiverを定義する -->
    <!-- ★ポイント1★ exported="false"により、明示的に非公開設定する -->
    <receiver
        android:name=".PrivateReceiver"
        android:exported="false" />
    
    <activity
        android:name=".PrivateSenderActivity"
        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>
PrivateReceiver.java
/*
 * Copyright (C) 2012-2021 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.broadcast.privatereceiver;

import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.widget.Toast;

public class PrivateReceiver extends BroadcastReceiver {

    @Override
    public void onReceive(Context context, Intent intent) {
                
        // ★ポイント2★ 同一アプリ内から送信されたBroadcastであっても、受信Intentの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        String param = intent.getStringExtra("PARAM");
        Toast.makeText(context,
                       String.format("「%s」を受信した。", param),
                       Toast.LENGTH_SHORT).show();
                
        // ★ポイント3★ 結果を返す場合、送信元は同一アプリ内であるから、センシティブな情報を返送してよい
        setResultCode(Activity.RESULT_OK);
        setResultData("センシティブな情報 from Receiver");
        abortBroadcast();
    }
}

次に非公開Broadcast ReceiverへBroadcast送信するサンプルコードを示す。

ポイント(Broadcastを送信する):

  1. 同一アプリ内Receiverはクラス指定の明示的IntentでBroadcast送信する
  2. 送信先は同一アプリ内Receiverであるため、センシティブな情報を送信してよい
  3. 同一アプリ内Receiverからの結果情報であっても、受信データの安全性を確認する
PrivateSenderActivity.java
/*
 * Copyright (C) 2012-2021 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.broadcast.privatereceiver;

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

public class PrivateSenderActivity extends Activity {

    public void onSendNormalClick(View view) {
        // ★ポイント4★ 同一アプリ内Receiverはクラス指定の明示的IntentでBroadcast送信する
        Intent intent = new Intent(this, PrivateReceiver.class);

        // ★ポイント5★ 送信先は同一アプリ内Receiverであるため、センシティブな情報を送信してよい
        intent.putExtra("PARAM", "センシティブな情報 from Sender");
        sendBroadcast(intent);
    }
        
    public void onSendOrderedClick(View view) {
        // ★ポイント4★ 同一アプリ内Receiverはクラス指定の明示的IntentでBroadcast送信する
        Intent intent = new Intent(this, PrivateReceiver.class);

        // ★ポイント5★ 送信先は同一アプリ内Receiverであるため、センシティブな情報を送信してよい
        intent.putExtra("PARAM", "センシティブな情報 from Sender");
        sendOrderedBroadcast(intent, null, mResultReceiver, null, 0, null, null);
    }
        
    private BroadcastReceiver mResultReceiver = new BroadcastReceiver() {
            @Override
            public void onReceive(Context context, Intent intent) {
                        
                // ★ポイント6★ 同一アプリ内Receiverからの結果情報であっても、受信データの安全性を確認する
                // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
                String data = getResultData();
                PrivateSenderActivity.this.logLine(
                        String.format("結果「%s」を受信した。", data));
            }
        };
        
    private TextView mLogView;
        
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        mLogView = (TextView)findViewById(R.id.logview);
    }
        
    private void logLine(String line) {
        mLogView.append(line);
        mLogView.append("\n");
    }
}

4.2.1.2. 公開Broadcast Receiver - Broadcastを受信する・送信する

公開Broadcast Receiverは、不特定多数のアプリから送信されたBroadcastを受信できるBroadcast Receiverである。マルウェアが送信したBroadcastを受信することがあることに注意が必要だ。

ポイント(Broadcastを受信する):

  1. exported="true"により、明示的に公開設定する
  2. 受信Intentの安全性を確認する
  3. 結果を返す場合、センシティブな情報を含めない

公開Broadcast ReceiverのサンプルコードであるPublic Receiverは、静的Broadcast Receiverおよび動的Broadcast Receiverの両方で利用される。

PublicReceiver.java
/*
 * Copyright (C) 2012-2021 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.broadcast.publicreceiver;

import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.widget.Toast;

public class PublicReceiver extends BroadcastReceiver {

    private static final String MY_BROADCAST_PUBLIC =
        "org.jssec.android.broadcast.MY_BROADCAST_PUBLIC";
        
    public boolean isDynamic = false;
    private String getName() {
        return isDynamic ? "公開動的 Broadcast Receiver" : "公開静的 Broadcast Receiver";
    }
        
    @Override
    public void onReceive(Context context, Intent intent) {
                
        // ★ポイント2★ 受信Intentの安全性を確認する
        // 公開Broadcast Receiverであるため利用元アプリがマルウェアである可能性がある。
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        if (MY_BROADCAST_PUBLIC.equals(intent.getAction())) {
            String param = intent.getStringExtra("PARAM");
            Toast.makeText(context,
                           String.format("%s:\n「%s」を受信した。", getName(), param),
                           Toast.LENGTH_SHORT).show();
        }
                
        // ★ポイント3★ 結果を返す場合、センシティブな情報を含めない
        // 公開Broadcast Receiverであるため、
        // Broadcastの送信元アプリがマルウェアである可能性がある。
        // マルウェアに取得されても問題のない情報であれば結果として返してもよい。
        setResultCode(Activity.RESULT_OK);
        setResultData(String.format("センシティブではない情報 from %s", getName()));
        abortBroadcast();
    }
}

静的Broadcast ReceiverはAndroidManifest.xmlで定義する。表 4.2.1 のように、端末のバージョンによって暗黙的Broadcst Intentの受信に制限があるので注意すること。

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.broadcast.publicreceiver" >


  <application
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name"
      android:allowBackup="false" >
    
    <!-- 公開静的Broadcast Receiverを定義する -->
    <!-- ★ポイント1★ exported="true"により、明示的に公開設定する -->
    <receiver android:name=".PublicReceiver"
              android:exported="true" >
      <intent-filter>
        <action android:name="org.jssec.android.broadcast.MY_BROADCAST_PUBLIC" />
      </intent-filter>
    </receiver>
    
    <service
        android:name=".DynamicReceiverService"
        android:exported="false" />
    
    <activity
        android:name=".PublicReceiverActivity"
        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>

動的Broadcast Receiverはプログラム中でregisterReceiver()およびunregisterReceiver()を呼び出すことにより登録/登録解除する。ボタン操作により登録/登録解除を行うためにPublicReceiverActivity上にボタンを配置している。動的Broadcast ReceiverインスタンスはPublicReceiverActivityより生存期間が長いためPublicReceiverActivityのメンバー変数として保持することはできない。そのためDynamicReceiverServiceのメンバー変数として動的Broadcast Receiverのインスタンスを保持させ、DynamicReceiverServiceをPublicReceiverActivityから開始/終了することにより動的Broadcast Receiverを間接的に登録/登録解除している。

DynamicReceiverService.java
/*
 * Copyright (C) 2012-2021 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.broadcast.publicreceiver;

import android.app.Service;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.IBinder;
import android.widget.Toast;

public class DynamicReceiverService extends Service {

    private static final String MY_BROADCAST_PUBLIC =
        "org.jssec.android.broadcast.MY_BROADCAST_PUBLIC";
        
    private PublicReceiver mReceiver;
        
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public void onCreate() {
        super.onCreate();
                
        // 公開動的Broadcast Receiverを登録する
        mReceiver = new PublicReceiver();
        mReceiver.isDynamic = true;
        IntentFilter filter = new IntentFilter();
        filter.addAction(MY_BROADCAST_PUBLIC);
        filter.setPriority(1);  // 静的Broadcast Receiverより動的Broadcast Receiverを優先させる
        registerReceiver(mReceiver, filter);
        Toast.makeText(this,
                       "動的 Broadcast Receiver を登録した。",
                       Toast.LENGTH_SHORT).show();
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
                
        // 公開動的Broadcast Receiverを登録解除する
        unregisterReceiver(mReceiver);
        mReceiver = null;
        Toast.makeText(this,
                       "動的 Broadcast Receiver を登録解除した。",
                       Toast.LENGTH_SHORT).show();
    }
}
PublicReceiverActivity.java
/*
 * Copyright (C) 2012-2021 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.broadcast.publicreceiver;

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

public class PublicReceiverActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }
        
    public void onRegisterReceiverClick(View view) {
        Intent intent = new Intent(this, DynamicReceiverService.class);
        startService(intent);
    }
        
    public void onUnregisterReceiverClick(View view) {
        Intent intent = new Intent(this, DynamicReceiverService.class);
        stopService(intent);
    }
}

次に公開Broadcast ReceiverへBroadcast送信するサンプルコードを示す。公開Broadcast ReceiverにBroadcastを送信する場合、送信するBroadcastがマルウェアに受信されることがあることに注意が必要である。

ポイント(Broadcastを送信する):

  1. センシティブな情報を送信してはならない
  2. 結果を受け取る場合、結果データの安全性を確認する
PublicSenderActivity.java
/*
 * Copyright (C) 2012-2021 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.broadcast.publicsender;

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

public class PublicSenderActivity extends Activity {
        
    private static final String MY_BROADCAST_PUBLIC =
        "org.jssec.android.broadcast.MY_BROADCAST_PUBLIC";
        
    public void onSendNormalClick(View view) {
        // ★ポイント4★ センシティブな情報を送信してはならない
        Intent intent = new Intent(MY_BROADCAST_PUBLIC);
        intent.putExtra("PARAM", "センシティブではない情報 from Sender");
        sendBroadcast(intent);
    }
        
    public void onSendOrderedClick(View view) {
        // ★ポイント4★ センシティブな情報を送信してはならない
        Intent intent = new Intent(MY_BROADCAST_PUBLIC);
        intent.putExtra("PARAM", "センシティブではない情報 from Sender");
        sendOrderedBroadcast(intent, null, mResultReceiver, null, 0, null, null);
    }
        
    public void onSendStickyClick(View view) {
        // ★ポイント4★ センシティブな情報を送信してはならない
        Intent intent = new Intent(MY_BROADCAST_PUBLIC);
        intent.putExtra("PARAM", "センシティブではない情報 from Sender");
        //sendStickyBroadcastメソッドはAPI Level 21でdeprecatedとなった
        sendStickyBroadcast(intent);
    }

    public void onSendStickyOrderedClick(View view) {
        // ★ポイント4★ センシティブな情報を送信してはならない
        Intent intent = new Intent(MY_BROADCAST_PUBLIC);
        intent.putExtra("PARAM", "センシティブではない情報 from Sender");
        //sendStickyBroadcastメソッドはAPI Level 21でdeprecatedとなった
        sendStickyOrderedBroadcast(intent, mResultReceiver, null, 0, null, null);
    }
        
    public void onRemoveStickyClick(View view) {
        Intent intent = new Intent(MY_BROADCAST_PUBLIC);
        //removeStickyBroadcastメソッドはdeprecatedとなっている
        removeStickyBroadcast(intent);
    }

    private BroadcastReceiver mResultReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
                        
            // ★ポイント5★ 結果を受け取る場合、結果データの安全性を確認する
            // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
            String data = getResultData();
            PublicSenderActivity.this.logLine(String.format("結果「%s」を受信した。",
                                                            data));
        }
    };

    private TextView mLogView;
        
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        mLogView = (TextView)findViewById(R.id.logview);
    }
        
    private void logLine(String line) {
        mLogView.append(line);
        mLogView.append("\n");
    }
}

4.2.1.3. 自社限定Broadcast Receiver - Broadcastを受信する・送信する

自社限定Broadcast Receiverは、自社以外のアプリから送信されたBroadcastを一切受信しないBroadcast Receiverである。複数の自社製アプリでシステムを構成し、自社アプリが扱う情報や機能を守るために利用される。

ポイント(Broadcastを受信する):

  1. Broadcast受信用の独自定義Signature Permissionを定義する
  2. 結果受信用の独自定義Signature Permissionを利用宣言する
  3. exported="true"により、明示的に公開設定する
  4. 静的Broadcast Receiver定義にて、独自定義Signature Permissionを要求宣言する
  5. 動的Broadcast Receiverを登録するとき、独自定義Signature Permissionを要求宣言する
  6. 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
  7. 自社アプリからのBroadcastであっても、受信Intentの安全性を確認する
  8. Broadcast送信元は自社アプリであるから、センシティブな情報を返送してよい
  9. APKをExportするときに、Broadcast送信元アプリと同じ開発者鍵でAPKを署名する

自社限定Broadcast ReceiverのサンプルコードであるProprietaryReciverは、静的Broadcast Receiverおよび動的Broadcast Receiverの両方で利用される。

InhouseReceiver.java
/*
 * Copyright (C) 2012-2021 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.broadcast.inhousereceiver;

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

import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.widget.Toast;

public class InhouseReceiver extends BroadcastReceiver {

    // 自社のSignature Permission
    private static final String MY_PERMISSION =
        "org.jssec.android.broadcast.inhousereceiver.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 static final String MY_BROADCAST_INHOUSE =
        "org.jssec.android.broadcast.MY_BROADCAST_INHOUSE";

    public boolean isDynamic = false;
    private String getName() {
        return isDynamic ? "自社限定動的 Broadcast Receiver" : "自社限定静的 Broadcast Receiver";
    }

    @Override
    public void onReceive(Context context, Intent intent) {

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

        // ★ポイント7★ 自社アプリからのBroadcastであっても、受信Intentの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        if (MY_BROADCAST_INHOUSE.equals(intent.getAction())) {
            String param = intent.getStringExtra("PARAM");
            Toast.makeText(context,
                           String.format("%s:\n「%s」を受信した。", getName(), param),
                           Toast.LENGTH_SHORT).show();
        }

        // ★ポイント8★ Broadcast送信元は自社アプリであるから、センシティブな情報を返送してよい
        setResultCode(Activity.RESULT_OK);
        setResultData(String.format("センシティブな情報 from %s", getName()));
        abortBroadcast();
    }
}

静的Broadcast ReceiverはAndroidManifest.xmlで定義する。表 4.2.1 のように、端末のバージョンによって暗黙的Broadcst Intentの受信に制限があるので注意すること。

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.broadcast.inhousereceiver" >

  <!-- ★ポイント1★ Broadcast受信用の独自定義Signature Permissionを定義する -->
  <permission
      android:name="org.jssec.android.broadcast.inhousereceiver.MY_PERMISSION"
      android:protectionLevel="signature" />

  <!-- ★ポイント2★ 結果受信用の独自定義Signature Permissionを利用宣言する -->
  <uses-permission
      android:name="org.jssec.android.broadcast.inhousesender.MY_PERMISSION" />

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

    <!-- ★ポイント3★ exported="true"により、明示的に公開設定する -->
    <!-- ★ポイント4★ 静的Broadcast Receiver定義にて、独自定義Signature Permissionを要求宣言する -->
    <receiver
        android:name="org.jssec.android.broadcast.inhousereceiver.InhouseReceiver"
        android:exported="true"
        android:permission="org.jssec.android.broadcast.inhousereceiver.MY_PERMISSION">
      <intent-filter>
        <action android:name="org.jssec.android.broadcast.MY_BROADCAST_INHOUSE" />
      </intent-filter>
    </receiver>

    <service
        android:name="org.jssec.android.broadcast.inhousereceiver.DynamicReceiverService"
        android:exported="false" />

    <activity
        android:name="org.jssec.android.broadcast.inhousereceiver.InhouseReceiverActivity"
        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>

動的Broadcast Receiverはプログラム中でregisterReceiver()およびunregisterReceiver()を呼び出すことにより登録/登録解除する。ボタン操作により登録/登録解除を行うためにProprietaryReceiverActivity上にボタンを配置している。動的Broadcast ReceiverインスタンスはProprietaryReceiverActivityより生存期間が長いためProprietaryReceiverActivityのメンバー変数として保持することはできない。そのためDynamicReceiverServiceのメンバー変数として動的Broadcast Receiverのインスタンスを保持させ、DynamicReceiverServiceをProprietaryReceiverActivityから開始/終了することにより動的Broadcast Receiverを間接的に登録/登録解除している。

InhouseReceiverActivity.java
/*
 * Copyright (C) 2012-2021 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.broadcast.inhousereceiver;

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

public class InhouseReceiverActivity extends Activity {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }
        
    public void onRegisterReceiverClick(View view) {
        Intent intent = new Intent(this, DynamicReceiverService.class);
        startService(intent);
    }
        
    public void onUnregisterReceiverClick(View view) {
        Intent intent = new Intent(this, DynamicReceiverService.class);
        stopService(intent);
    }}
DynamicReceiverService.java
/*
 * Copyright (C) 2012-2021 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.broadcast.inhousereceiver;

import android.app.Service;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.IBinder;
import android.widget.Toast;

public class DynamicReceiverService extends Service {

    private static final String MY_BROADCAST_INHOUSE =
        "org.jssec.android.broadcast.MY_BROADCAST_INHOUSE";

    private InhouseReceiver mReceiver;

    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public void onCreate() {
        super.onCreate();

        mReceiver = new InhouseReceiver();
        mReceiver.isDynamic = true;
        IntentFilter filter = new IntentFilter();
        filter.addAction(MY_BROADCAST_INHOUSE);
        filter.setPriority(1);  // 静的Broadcast Receiverより動的Broadcast Receiverを優先させる

        // ★ポイント5★ 動的Broadcast Receiverを登録するとき、独自定義Signature Permissionを要求宣言する
        registerReceiver(mReceiver, filter,
                "org.jssec.android.broadcast.inhousereceiver.MY_PERMISSION",
                null);

        Toast.makeText(this,
                       "動的 Broadcast Receiver を登録した。",
                       Toast.LENGTH_SHORT).show();
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        unregisterReceiver(mReceiver);
        mReceiver = null;
        Toast.makeText(this,
                       "動的 Broadcast Receiver を登録解除した。",
                       Toast.LENGTH_SHORT).show();
    }
}
SigPerm.java
/*
 * Copyright (C) 2012-2021 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;
        }
    }
}
PkgCert.java
/*
 * Copyright (C) 2012-2021 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();
    }
}

★ポイント9★APKをExportするときに、Broadcast送信元アプリと同じ開発者鍵でAPKを署名する。

_images/image34.png

図 4.2.2 Broadcast 送信元アプリと同じ開発者鍵で APK を署名する

次に自社限定Broadcast ReceiverへBroadcast送信するサンプルコードを示す。

ポイント(Broadcastを送信する):

  1. 結果受信用の独自定義Signature Permissionを定義する
  2. Broadcast受信用の独自定義Signature Permissionを利用宣言する
  3. 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
  4. Receiverは自社アプリ限定であるから、センシティブな情報を送信してもよい
  5. Receiverに独自定義Signature Permissionを要求する
  6. 結果を受け取る場合、結果データの安全性を確認する
  7. APKをExportするときに、Receiver側アプリと同じ開発者鍵でAPKを署名する
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.broadcast.inhousesender" >

  <queries>
    <package android:name="org.jssec.android.broadcast.inhousereceiver" />
  </queries>
  
  <uses-permission android:name="android.permission.BROADCAST_STICKY"/>

  <!-- ★ポイント10★ 結果受信用の独自定義Signature Permissionを定義する -->
  <permission
      android:name="org.jssec.android.broadcast.inhousesender.MY_PERMISSION"
      android:protectionLevel="signature" />

  <!-- ★ポイント11★ Broadcast受信用の独自定義Signature Permissionを利用宣言する -->
  <uses-permission
      android:name="org.jssec.android.broadcast.inhousereceiver.MY_PERMISSION" />

  <application
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name"
      android:allowBackup="false" >
    <activity
        android:name="org.jssec.android.broadcast.inhousesender.InhouseSenderActivity"
        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>
InhouseSenderActivity.java
/*
 * Copyright (C) 2012-2021 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.broadcast.inhousesender;

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

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

public class InhouseSenderActivity extends Activity {

    // 自社のSignature Permission
    private static final String MY_PERMISSION =
        "org.jssec.android.broadcast.inhousesender.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 static final String MY_BROADCAST_INHOUSE =
        "org.jssec.android.broadcast.MY_BROADCAST_INHOUSE";

    public void onSendNormalClick(View view) {

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

        // ★ポイント13★ Receiverは自社アプリ限定であるから、センシティブな情報を送信してもよい
        Intent intent = new Intent(MY_BROADCAST_INHOUSE);
        intent.putExtra("PARAM", "センシティブな情報 from Sender");

        // ★ポイント14★ Receiverに独自定義Signature Permissionを要求する
        sendBroadcast(intent, "org.jssec.android.broadcast.inhousesender.MY_PERMISSION");
    }

    public void onSendOrderedClick(View view) {

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

        // ★ポイント13★ Receiverは自社アプリ限定であるから、センシティブな情報を送信してもよい
        Intent intent = new Intent(MY_BROADCAST_INHOUSE);
        intent.putExtra("PARAM", "センシティブな情報 from Sender");

        // ★ポイント14★ Receiverに独自定義Signature Permissionを要求する
        sendOrderedBroadcast(intent,
                            "org.jssec.android.broadcast.inhousesender.MY_PERMISSION",
                             mResultReceiver, null, 0, null, null);
    }

    private BroadcastReceiver mResultReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {

            // ★ポイント15★ 結果を受け取る場合、結果データの安全性を確認する
            // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
            String data = getResultData();
            InhouseSenderActivity.this.logLine(String.format("結果「%s」を受信した。", data));
        }
    };

    private TextView mLogView;

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

    private void logLine(String line) {
        mLogView.append(line);
        mLogView.append("\n");
    }
}
SigPerm.java
/*
 * Copyright (C) 2012-2021 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;
        }
    }
}
PkgCert.java
/*
 * Copyright (C) 2012-2021 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();
    }
}

★ポイント16★APKをExportするときに、Receiver側アプリと同じ開発者鍵でAPKを署名する。

_images/image34.png

図 4.2.3 Reciver 側アプリと同じ開発者鍵で APK を署名する

4.2.2. ルールブック

Broadcastを送受信する際には以下のルールを守ること。

  1. アプリ内でのみ使用するBroadcast Receiverは非公開設定する (必須)
  2. 受信Intentの安全性を確認する (必須)
  3. 独自定義Signature Permissionは、自社アプリが定義したことを確認して利用する (必須)
  4. 結果情報を返す場合には、返送先アプリからの結果情報漏洩に注意する (必須)
  5. センシティブな情報をBroadcast送信する場合は、受信可能なReceiverを制限する (必須)
  6. Sticky Broadcastにはセンシティブな情報を含めない (必須)
  7. receiverPermissionパラメータの指定なしOrdered Broadcastは届かないことがあることに注意 (必須)
  8. Broadcast Receiverからの返信データの安全性を確認する (必須)
  9. 資産を二次的に提供する場合には、その資産の従来の保護水準を維持する (必須)

4.2.2.1. アプリ内でのみ使用するBroadcast Receiverは非公開設定する (必須)

アプリ内でのみ使用されるBroadcast Receiverは非公開設定する。これにより、他のアプリから意図せずBroadcastを受け取ってしまうことがなくなり、アプリの機能を利用されたり、アプリの動作に異常をきたしたりするのを防ぐことができる。

同一アプリ内からのみ利用されるReceiverではIntent Filterを設置するような設計はしてはならない。Intent Filterの性質上、同一アプリ内の非公開Receiverを呼び出すつもりでも、Intent Filter経由で呼び出したときに意図せず他アプリの公開Receiverを呼び出してしまう場合が存在するからである。

AndroidManifest.xml(非推奨)
    <!-- 外部アプリに非公開とするBroadcast Receiver -->
    <!-- 4.2.1.1 - ポイント1: exported="false"により、明示的に非公開設定する -->
    <receiver android:name=".PrivateReceiver"
        android:exported="false" >
        <intent-filter>
            <action android:name="org.jssec.android.broadcast.MY_ACTION" />
        </intent-filter>
    </receiver>

4.2.3.1. 使用してよいexported 設定とintent-filter設定の組み合わせ(Receiverの場合)」も参照すること。

4.2.2.2. 受信Intentの安全性を確認する (必須)

Broadcast Receiverのタイプによって若干リスクは異なるが、受信Intentのデータを処理する際には、まず受信Intentの安全性を確認しなければならない。

公開Broadcast Receiverは不特定多数のアプリからIntentを受け取るため、マルウェアの攻撃Intentを受け取る可能性がある。非公開Broadcast Receiverは他のアプリからIntentを直接受け取ることはない。しかし同一アプリ内の公開Componentが他のアプリから受け取ったIntentのデータを非公開Broadcast Receiverに転送することがあるため、受信Intentを無条件に安全であると考えてはならない。自社限定Broadcast Receiverはその中間のリスクであるため、やはり受信Intentの安全性を確認する必要がある。

3.2. 入力データの安全性を確認する」を参照すること。

4.2.2.3. 独自定義Signature Permissionは、自社アプリが定義したことを確認して利用する (必須)

自社のアプリから送信されたBroadcastだけを受信し、それ以外のBroadcastを一切受信しない自社限定Broadcast Receiverを作る場合、独自定義Signature Permissionにより保護しなければならない。AndroidManifest.xmlでのPermission定義、Permission要求宣言だけでは保護が不十分であるため、「5.2. PermissionとProtection Level」の「5.2.1.2. 独自定義のSignature Permissionで自社アプリ連携する方法」を参照すること。また独自定義Signature PermissionをreceiverPermissionパラメータに指定してBroadcast送信する場合も同様に確認する必要がある。

4.2.2.4. 結果情報を返す場合には、返送先アプリからの結果情報漏洩に注意する (必須)

Broadcast ReceiverのタイプによってsetResult()により結果情報を返すアプリの信用度が異なる。公開Broadcast Receiverの場合は、結果返送先のアプリがマルウェアである可能性もあり、結果情報が悪意を持って使われる危険性がある。非公開Broadcast Receiverや自社限定Broadcast Receiverの場合は、結果返送先は自社開発アプリであるため結果情報の扱いをあまり心配する必要はない。

このようにBroadcast Receiverから結果情報を返す場合には、返送先アプリからの結果情報の漏洩に配慮しなければならない。

4.2.2.5. センシティブな情報をBroadcast送信する場合は、受信可能なReceiverを制限する (必須)

Broadcastという名前が表すように、そもそもBroadcastは不特定多数のアプリに情報を一斉送信したり、タイミングを通知したりすることを意図して作られた仕組みである。そのためセンシティブな情報をBroadcast送信する場合には、マルウェアに情報を取得されないような注意深い設計が必要となる。

センシティブな情報をBroadcast送信する場合、信頼できるBroadcast Receiverだけが受信可能であり、他のBroadcast Receiverは受信不可能である必要がある。そのためのBroadcast送信方法には以下のようなものがある。

  • 明示的IntentでBroadcast送信することで宛先を固定し、意図した信頼できるBroadcast ReceiverだけにBroadcastを届ける方法。この方法には次の2つのパターンがある。
    • 同一アプリ内Broadcast Receiver宛てであればIntent#setClass(Context, Class)により宛先を限定する。具体的なコードについてはサンプルコードセクション「4.2.1.1. 非公開Broadcast Receiver - Broadcastを受信する・送信する」を参考にすること。
    • 他のアプリのBroadcast Receiver宛てであればIntent#setClassName(String, String)により宛先を限定するが、Broadcast送信に先立ち宛先パッケージのAPK署名の開発者鍵をホワイトリストと照合して許可したアプリであることを確認してからBroadcastを送信する。実際には 暗黙的Intentを利用できる次の方法が実用的である。
  • receiverPermissionパラメータに独自定義Signature Permissionを指定してBroadcast送信し、信頼するBroadcast Receiverに当該Signature Permissionを利用宣言してもらう方法。具体的なコード についてはサンプルコードセクション「4.2.1.3. 自社限定Broadcast Receiver - Broadcastを受信する・送信する」を参考にすること。またこのBroadcast送信方法を実装するにはルール 「4.2.2.3. 独自定義Signature Permissionは、自社アプリが定義したことを確認して利用する (必須)」も適用しなければならない。

4.2.2.6. Sticky Broadcastにはセンシティブな情報を含めない (必須)

通常のBroadcastは、Broadcast送信時に受信可能状態にあるBroadcast Receiverに受信処理されると、そのBroadcastは消滅してしまう。一方Sticky Broadcast(およびSticky Ordered Broadcast、以下同様)は、送信時に受信状態にあるBroadcast Receiverに受信処理された後もシステム上に存在しつづけ、その後registerReceiver()により受信できることが特徴である。不要になったSticky BroadcastはremoveStickyBroadcast()により任意のタイミングで削除できる。

Sticky Broadcastは暗黙的Intentによる使用が前提であり、receiverPermissionパラメータを指定したBroadcast送信はできない。そのためSticky Broadcastで送信した情報はマルウェアを含む不特定多数のアプリから取得できてしまう。したがってセンシティブな情報をSticky Broadcastで送信してはならない。なお、Sticky Broadcastの使用はAndroid 5.0(API Level 21)において非推奨となっている。

4.2.2.7. receiverPermissionパラメータの指定なしOrdered Broadcastは届かないことがあることに注意 (必須)

receiverPermissionパラメータを指定せずに送信されたOrdered Broadcastは、マルウェアを含む不特定多数のアプリが受信可能である。Ordered BroadcastはReceiverからの返り情報を受け取るため、または複数のReceiverに順次処理をさせるために利用される。優先度の高いReceiverから順次Broadcastが配送されるため、優先度を高くしたマルウェアが最初にBroadcastを受信しabortBroadcast()すると、後続のReceiverにBroadcastが配信されなくなる。

4.2.2.8. Broadcast Receiverからの返信データの安全性を確認する (必須)

結果データを送り返してきたBroadcast Receiverのタイプによって若干リスクは異なるが、基本的には受信した結果データが攻撃データである可能性を考慮して安全に処理しなければならない。

返信元Broadcast Receiverが公開Broadcast Receiverの場合、不特定のアプリから戻りデータを受け取るため、マルウェアの攻撃データを受け取る可能性がある。返信元Broadcast Receiverが非公開Broadcast Receiverの場合、同一アプリ内からの結果データであるのでリスクはないように考えがちだが、他のアプリから受け取ったデータを間接的に結果データとして転送することがあるため、結果データを無条件に安全であると考えてはならない。返信元Broadcast Receiverが自社限定Broadcast Receiverの場合、その中間のリスクであるため、やはり結果データが攻撃データである可能性を考慮して安全に処理しなければならない。

3.2. 入力データの安全性を確認する」を参照すること。

4.2.2.9. 資産を二次的に提供する場合には、その資産の従来の保護水準を維持する (必須)

Permissionにより保護されている情報資産および機能資産を他のアプリに二次的に提供する場合には、提供先アプリに対して同一のPermissionを要求するなどして、その保護水準を維持しなければならない。AndroidのPermissionセキュリティモデルでは、保護された資産に対するアプリからの直接アクセスについてのみ権限管理を行う。この仕様上の特性により、アプリに取得された資産がさらに他のアプリに、保護のために必要なPermissionを要求することなく提供される可能性がある。このことはPermissionを再委譲していることと実質的に等価なので、Permissionの再委譲問題と呼ばれる。「5.2.3.4. Permissionの再委譲問題」を参照すること。

4.2.3. アドバンスト

4.2.3.1. 使用してよいexported 設定とintent-filter設定の組み合わせ(Receiverの場合)

表 4.2.2 は、Receiverを実装するときに使用してもよいexported属性とintent-filter要素の組み合わせを示している。「exported="false"かつintent-filter定義あり」の使用を原則禁止としている理由については以下で説明する。

表 4.2.2 exported 属性と intent-filter 要素の組み合わせの使用可否
  exported属性の値
true false 無指定
intent-filter定義がある 原則禁止 禁止
intent-filter定義がない 禁止

Receiverのexported属性が無指定である場合にそのReceiverが公開されるか非公開となるかは、intent-filterの定義の有無により決まるが [12]、本ガイドではReceiverのexported属性を「無指定」にすることを禁止している。前述のようなAPIのデフォルトの挙動に頼る実装をすることは避けるべきであり、exported属性のようなセキュリティ上重要な設定を明示的に有効化する手段があるのであればそれを利用すべきであると考えられるためである。

[12]intent-filterが定義されていれば公開Receiver、定義されていなければ非公開Receiverとなる。 https://developer.android.com/guide/topics/manifest/receiver-element.html#exported を参照のこと。

intent-filterを定義し、かつ、exported="false"を指定することを原則禁止としているのは、同一アプリ内の非公開Receiverに向けてBroadcastを送信したつもりでも、意図せず他アプリの公開Receiverを呼び出してしまう場合が存在するからである。以下の2つの図で意図せぬ呼び出しが起こる様子を説明する。

図 4.2.4 は、同一アプリ内からしか非公開Receiver(アプリA)を暗黙的Intentで呼び出せない正常な動作の例である。Intent-filter(図中action="X")を定義しているのが、アプリAしかいないので意図通りの動きとなっている。

_images/image40.png

図 4.2.4 正常な動作の例

図 4.2.5 は、アプリAに加えてアプリBでも同じintent-filter(図中action="X")を定義している場合である。まず、他のアプリ(アプリC)が暗黙的IntentでBroadcastを送信するのは、非公開Receiver(A-1)側は受信をしないので特にセキュリティ的には問題にならない(図の橙色の矢印)。

セキュリティ面で問題になるのは、アプリAによる同一アプリ内の非公開Receiverの呼び出しである。アプリAが暗黙的IntentをBroadcastすると、同一アプリ内の非公開Receiverに加えて、同じIntent-filterを定義したBの持つ公開Receiver(B-1)もそのIntentを受信できてしまうからである(図の赤色の矢印)。アプリAからアプリBに対してセンシティブな情報を送信する可能性が生じてしまう。アプリBがマルウェアであれば、そのままセンシティブな情報の漏洩に繋がる。また、BroadcastがOrdered Broadcastであった場合は、意図しない結果情報を受け取ってしまう可能性もある。

_images/image41.png

図 4.2.5 意図しない動作の例

ただし、システムの送信するBroadcast Intentのみを受信するBroadcastReceiverを実装する場合には、「exported="false"かつintent-filter定義あり」を使用すること。かつ、これ以外の組み合わせは使っていけない。これは、システムが送信するBroadcast Intentに関してはexported="false"でも受信可能であるという事実にもとづく。システムが送信するBroadcast Intentと同じACTIONのIntentを他アプリが送信した場合、それを受信してしまうと意図しない動作を引き起こす可能性があるが、これはexported="false"を指定することによって防ぐことができる。

4.2.3.2. Receiverはアプリを起動しないと登録されない

AndroidManifest.xmlに静的に定義したBroadcast Receiverは、インストールしただけでは有効にならないので注意が必要である [13]。アプリを1回起動することで、それ以降のBroadcastを受信できるようになるため、インストール後にBroadcast受信をトリガーにして処理を起動させることはできない。ただしBroadcastの送信側でIntentにIntent.FLAG_INCLUDE_STOPPED_PACKAGES を設定してBroadcast送信した場合は、一度も起動していないアプリであってもこのBroadcast を受信することができる。

[13]Android 3.0未満ではアプリのインストールをしただけでReceiverが登録される

4.2.3.3. 同じUIDを持つアプリから送信されたBroadcastは、非公開Broadcast Receiverでも受信できる

複数のアプリに同じUIDを持たせることができる。この場合、たとえ非公開Broadcast Receiverであっても、同じUIDのアプリから送信されたBroadcastは受信してしまう。

しかしこれはセキュリティ上問題となることはない。同じUIDを持つアプリはAPKを署名する開発者鍵が一致することが保証されており、非公開Broadcast Receiverが受信するのは自社アプリから送信されたBroadcastに限定されるからである。

4.2.3.4. Broadcastの種類とその特徴

送信するBroadcastはOrderedかそうでないか、Stickyかそうでないかの組み合わせにより4種類のBroadcastが存在する。Broadcast送信用メソッドに応じて、送信するBroadcastの種類が決まる。なお、Sticky Broadcastの使用はAndroid 5.0(API Level 21)において非推奨となっている。

表 4.2.3 送信する Broadcast の種類
Broadcastの種類 送信用メソッド Ordered? Sticky?
Normal Broadcast sendBroadcast() No No
Ordered Broadcast sendOrderedBroadcast() Yes No
Sticky Broadcast sendStickyBroadcast() No Yes
Sticky Ordered Broadcast sendStickyOrderedBroadcast() Yes Yes

それぞれのBroadcastの特徴は次のとおりである。

表 4.2.4 送信する Broadcast の種類ごとの特徴
Broadcastの種類 Broadcastの種類ごとの特徴
Normal Broadcast Normal Broadcastは送信時に受信可能な状態にあるBroadcast Receiverに配送されて消滅する。 Ordered Broadcastと異なり、複数のBroadcast Receiverが同時にBroadcastを受信するのが特徴である。 特定のPermissionを持つアプリのBroadcastReceiverだけにBroadcastを受信させることもできる。
Ordered Broadcast Ordered Broadcastは送信時に受信可能な状態にあるBroadcastReceiverが一つずつ順番にBroadcastを 受信することが特徴である。よりpriority値が大きいBroadcast Receiverが先に受信する。 すべてのBroadcast Receiverに配送完了するか、途中のBroadcast ReceiverがabortBroadcast()を呼び 出した場合に、Broadcastは消滅する。特定のPermissionを利用宣言したアプリのBroadcast Receiver だけにBroadcastを受信させることもできる。またOrdered Broadcastでは送信元がBroadcast Receiver からの結果情報を受け取ることもできる。SMS受信通知Broadcast(SMS_RECEIVED)はOrdered Broadcastの代表例である。
Sticky Broadcast Sticky Broadcastは送信時に受信可能な状態にあるBroadcastReceiverに配送された後に消滅することは なくシステムに残り続け、後にregisterReceiver()を呼び出したアプリがSticky Broadcastを受信する ことができることが特徴である。Sticky Broadcastは他のBroadcastと異なり自動的に消滅することはな いので、Sticky Broadcastが不要になったときに、明示的にremoveStickyBroadcast()を呼び出して Sticky Broadcastを消滅させる必要がある。他のBroadcastと異なり、特定のPermissionを持つアプリの Broadcast ReceiverだけにBroadcastを受信させることはできない。バッテリー状態変更通知Broadcast (ACTION_BATTERY_CHANGED)はSticky Broadcastの代表例である。
Sticky Ordered Broadcast Ordered BroadcastとSticky Broadcastの両方の特徴を持ったBroadcastである。Sticky Broadcast と同様、特定のPermissionを持つアプリのBroadcast ReceiverだけにBroadcastを受信させることは できない。

Broadcastの特徴的な振る舞いの視点で、上表を逆引き的に再整理すると下表になる。

表 4.2.5 Broadcast の特徴的な振る舞い
Broadcastの特徴的な振る舞い Normal Broadcast Ordered Broadcast Sticky Broadcast Sticky Ordered Broadcast
受信可能なBroadcast ReceiveをPermissionにより制限する o o - -
Broadcast Receiverからの処理結果を取得する - o - o
順番にBroadcast ReceiverにBroadcastを処理させる - o - o
既に送信されているBroadcastを後から受信する - - o o

4.2.3.5. Broadcast送信した情報がLogCatに出力される場合がある

Broadcastの送受信は基本的にLogCatに出力されない。しかし、受信側のPermission不足によるエラーや、送信側のPermission不足によるエラーの際にLogCatにエラーログが出力される。エラーログにはBroadcastで送信するIntent情報も含まれるので、エラー発生時にはBroadcast送信する場合はLogCatに表示されることに注意してほしい。

送信側のPermission不足時のエラー

W/ActivityManager(266): Permission Denial: broadcasting Intent {
act=org.jssec.android.broadcastreceiver.creating.action.MY_ACTION }
from org.jssec.android.broadcast.sending (pid=4685, uid=10058) requires
org.jssec.android.permission.MY_PERMISSION due to receiver
org.jssec.android.broadcastreceiver.creating/org.jssec.android.broadcastreceiver.creating.CreatingType3Receiver

受信側のPermission不足時のエラー

W/ActivityManager(275): Permission Denial: receiving Intent {
act=org.jssec.android.broadcastreceiver.creating.action.MY_ACTION } to
org.jssec.android.broadcastreceiver.creating requires
org.jssec.android.permission.MY_PERMISSION due to sender
org.jssec.android.broadcast.sending (uid 10158)

4.2.3.6. ホーム画面(アプリ)にショートカットを配置する際の注意点

ホーム画面にアプリを起動するためのショートカットボタンやWebブラウザのブックマークのようなURLショートカットを作成する場合の注意点について説明する。例として、以下のような実装を考えてみる。

ホーム画面(アプリ)にショートカットを配置する

        Intent targetIntent = new Intent(this, TargetActivity.class);

       // ショートカット作成依頼のためのIntent
        Intent intent = new Intent("com.android.launcher.action.INSTALL_SHORTCUT");

        // ショートカットのタップ時に起動するIntentを指定
        intent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, targetIntent);
        Parcelable icon = Intent.ShortcutIconResource.fromContext(context, iconResource);
        intent.putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE, icon);
        intent.putExtra(Intent.EXTRA_SHORTCUT_NAME, title);
        intent.putExtra("duplicate", false);

        // BroadCastを使って、システムにショートカット作成を依頼する
        context.sendBroadcast(intent);

上記で送信しているBroadcastは、受け手がホーム画面アプリであり、パッケージ名を特定することが難しいため、暗黙的Intentによる公開Receiverへの送信となっていることに注意が必要である。つまり、上記で送信したBroadcastはマルウェアを含めた任意のアプリが受信することができ、そのため、Intentにセンシティブな情報が含まれていると情報漏洩の被害につながる可能性がある。URLを基にしたショートカットを作成する場合、URLに秘密の情報が含まれていることもあるため、特に注意が必要である。

対策方法としては、「4.2.1.2. 公開Broadcast Receiver - Broadcastを受信する・送信する」に記載されているポイントに従い、送信するIntentにセンシティブな情報が含まないようにすることが必要である。

4.2.3.7. ACTION_CLOSE_SYSTEM_DIALOGSについて

ACTION_CLOSE_SYSTEM_DIALOGSは、システムダイアログが閉じられたことを意味するBroadcast Intentである。また、アプリからBroadcast送信することで、システムダイアログを閉じることができる。このACTION_CLOSE_SYSTEM_DIALOGSは、Android 12をターゲットとするかAndroid11以前をターゲットとするかで以下の通り挙動が変更になっている。

Android 11以前をターゲットとする場合:

インテントは実行されずlogcatには次のメッセージが表示される。

E ActivityTaskManager Permission Denial: \
android.intent.action.CLOSE_SYSTEM_DIALOGS broadcast from \
com.package.name requires android.permission.BROADCAST_CLOSE_SYSTEM_DIALOGS, \
dropping broadcast.

ただし、次の場合においては、引き続きシステムダイアログを閉じることができる。

  • 通知ドロワーの上にWindowを表示している
  • ユーザーが通知を操作し、アプリがそのユーザーアクションに応じてサービスまたはBroadcast Receiverを処理している
  • ユーザー補助サービスが有効になっている

Android 12をターゲットとする場合:

ACTION_CLOSE_SYSTEM_DIALOGSは非推奨となった。アプリがこのアクションを含むインテントの呼び出しを試みるとSecurityExceptionが発生する。

ただし、次の場合においては、引き続きシステムダイアログを閉じることができる。

  • アプリがインストゥルメント化単体テストを実行している場合

ユーザー補助サービスが有効になっており、通知バーを閉じる必要がある場合は、代わりにGLOBAL_ACTION_DISMISS_NOTIFICATION_SHADE ユーザー補助アクションを使用する。

4.3. Content Providerを作る・利用する

ContentResolverとSQLiteDatabaseのインターフェースが似ているため、Content ProviderはSQLiteDatabaseと密接に関係があると勘違いされることが多い。しかし実際にはContent Providerはアプリ間データ共有のインターフェースを規定するだけで、データ保存の形式は一切問わないことに注意が必要だ。作成するContent Provider内部でデータの保存にSQLiteDatabaseを使うこともできるし、XMLファイルなどの別の保存形式を使うこともできる。なお、ここで紹介するサンプルコードにはデータを保存する処理を含まないので、必要に応じて追加すること。

4.3.1. サンプルコード

Content Providerがどのように利用されるかによって、Content Providerが抱えるリスクや適切な防御手段が異なる。次の判定フローによって作成するContent Providerがどのタイプであるかを判断できる。

_images/image42.png

図 4.3.1 Content Provider タイプ判定フロー

4.3.1.1. 非公開Content Providerを作る・利用する

非公開Content Providerは、同一アプリ内だけで利用されるContent Providerであり、 もっとも安全性の高いContent Providerである [14]

[14]ただし、Content Providerの非公開設定はAndroid 2.2 (API Level 8) 以前では機能しない。

以下、非公開Content Providerの実装例を示す。

ポイント(Content Providerを作る):

  1. exported="false"により、明示的に非公開設定する
  2. 同一アプリ内からのリクエストであっても、パラメータの安全性を確認する
  3. 利用元アプリは同一アプリであるから、センシティブな情報を返送してよい
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.provider.privateprovider">

  <application
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name" >
    <activity
        android:name=".PrivateUserActivity"
        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>
    
    <!-- ★ポイント1★ exported="false"により、明示的に非公開設定する -->
    <provider
        android:name=".PrivateProvider"
        android:authorities="org.jssec.android.provider.privateprovider"
        android:exported="false" />
  </application>
</manifest>
PrivateProvider.java
/*
 * Copyright (C) 2012-2021 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.provider.privateprovider;

import android.content.ContentProvider;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;

public class PrivateProvider extends ContentProvider {

    public static final String AUTHORITY = "org.jssec.android.provider.privateprovider";
    public static final String CONTENT_TYPE =
        "vnd.android.cursor.dir/vnd.org.jssec.contenttype";
    public static final String CONTENT_ITEM_TYPE =
        "vnd.android.cursor.item/vnd.org.jssec.contenttype";

    // Content Providerが提供するインターフェースを公開
    public interface Download {
        public static final String PATH = "downloads";
        public static final Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITY + "/" + PATH);
    }
    public interface Address {
        public static final String PATH = "addresses";
        public static final Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITY + "/" + PATH);
    }

    // UriMatcher
    private static final int DOWNLOADS_CODE = 1;
    private static final int DOWNLOADS_ID_CODE = 2;
    private static final int ADDRESSES_CODE = 3;
    private static final int ADDRESSES_ID_CODE = 4;
    private static UriMatcher sUriMatcher;
    static {
        sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
        sUriMatcher.addURI(AUTHORITY, Download.PATH, DOWNLOADS_CODE);
        sUriMatcher.addURI(AUTHORITY, Download.PATH + "/#", DOWNLOADS_ID_CODE);
        sUriMatcher.addURI(AUTHORITY, Address.PATH, ADDRESSES_CODE);
        sUriMatcher.addURI(AUTHORITY, Address.PATH + "/#", ADDRESSES_ID_CODE);
    }

    // DBを使用せずに固定値を返す例にしているため、queryメソッドで返すCursorを事前に定義
    private static MatrixCursor sAddressCursor =
        new MatrixCursor(new String[] { "_id", "pref" });
    static {
        sAddressCursor.addRow(new String[] { "1", "北海道" });
        sAddressCursor.addRow(new String[] { "2", "青森" });
        sAddressCursor.addRow(new String[] { "3", "岩手" });
    }
    private static MatrixCursor sDownloadCursor =
        new MatrixCursor(new String[] { "_id", "path" });
    static {
        sDownloadCursor.addRow(new String[] { "1", "/sdcard/downloads/sample.jpg" });
        sDownloadCursor.addRow(new String[] { "2", "/sdcard/downloads/sample.txt" });
    }

    @Override
    public boolean onCreate() {
        return true;
    }

    @Override
    public String getType(Uri uri) {

        // ★ポイント2★ 同一アプリ内からのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // 「3.2 入力データの安全性を確認する」を参照。
        // ★ポイント3★ 利用元アプリは同一アプリであるから、センシティブな情報を返送してよい
        // ただしgetTypeの結果がセンシティブな意味を持つことはあまりない。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
        case ADDRESSES_CODE:
            return CONTENT_TYPE;

        case DOWNLOADS_ID_CODE:
        case ADDRESSES_ID_CODE:
            return CONTENT_ITEM_TYPE;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {

        // ★ポイント2★ 同一アプリ内からのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // その他のパラメータの確認はサンプルにつき省略。「3.2 入力データの安全性を確認する」を参照。
        // ★ポイント3★ 利用元アプリは同一アプリであるから、センシティブな情報を返送してよい
        // queryの結果がセンシティブな意味を持つかどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
        case DOWNLOADS_ID_CODE:
            return sDownloadCursor;

        case ADDRESSES_CODE:
        case ADDRESSES_ID_CODE:
            return sAddressCursor;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {

        // ★ポイント2★ 同一アプリ内からのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // その他のパラメータの確認はサンプルにつき省略。「3.2 入力データの安全性を確認する」を参照。
        // ★ポイント3★ 利用元アプリは同一アプリであるから、センシティブな情報を返送してよい
        // Insert結果、発番されるIDがセンシティブな意味を持つかどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return ContentUris.withAppendedId(Download.CONTENT_URI, 3);

        case ADDRESSES_CODE:
            return ContentUris.withAppendedId(Address.CONTENT_URI, 4);

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection,
                      String[] selectionArgs) {

        // ★ポイント2★ 同一アプリ内からのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // その他のパラメータの確認はサンプルにつき省略。「3.2 入力データの安全性を確認する」を参照。
        // ★ポイント3★ 利用元アプリは同一アプリであるから、センシティブな情報を返送してよい
        // Updateされたレコード数がセンシティブな意味を持つかどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return 5;       // updateされたレコード数を返す

        case DOWNLOADS_ID_CODE:
            return 1;

        case ADDRESSES_CODE:
            return 15;

        case ADDRESSES_ID_CODE:
            return 1;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {

        // ★ポイント2★ 同一アプリ内からのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // その他のパラメータの確認はサンプルにつき省略。「3.2 入力データの安全性を確認する」を参照。
        // ★ポイント3★ 利用元アプリは同一アプリであるから、センシティブな情報を返送してよい
        // Deleteされたレコード数がセンシティブな意味を持つかどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return 10;      // deleteされたレコード数を返す

        case DOWNLOADS_ID_CODE:
            return 1;

        case ADDRESSES_CODE:
            return 20;

        case ADDRESSES_ID_CODE:
            return 1;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }
}

次に、非公開Content Providerを利用するActivityの例を示す。

ポイント(Content Providerを利用する):

  1. 同一アプリ内へのリクエストであるから、センシティブな情報をリクエストに含めてよい
  2. 同一アプリ内からの結果情報であっても、受信データの安全性を確認する
PrivateUserActivity.java
/*
 * Copyright (C) 2012-2021 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.provider.privateprovider;

import android.app.Activity;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;

public class PrivateUserActivity extends Activity {

    public void onQueryClick(View view) {

        logLine("[Query]");

        // ★ポイント4★ 同一アプリ内へのリクエストであるから、センシティブな情報をリクエストに含めてよい
        Cursor cursor = null;
        try {
            cursor = getContentResolver().query(PrivateProvider.Download.CONTENT_URI,
						null, null, null, null);

            // ★ポイント5★ 同一アプリ内からの結果情報であっても、受信データの安全性を確認する
            // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
            if (cursor == null) {
                logLine("  null cursor");
            } else {
                boolean moved = cursor.moveToFirst();
                while (moved) {
                    logLine(String.format("  %d, %s",
                                          cursor.getInt(0),
                                          cursor.getString(1)));
                    moved = cursor.moveToNext();
                }
            }
        }
        finally {
            if (cursor != null) cursor.close();
        }
    }

    public void onInsertClick(View view) {

        logLine("[Insert]");

        // ★ポイント4★ 同一アプリ内へのリクエストであるから、センシティブな情報をリクエストに含めてよい
        Uri uri = getContentResolver().insert(PrivateProvider.Download.CONTENT_URI, null);

        // ★ポイント5★ 同一アプリ内からの結果情報であっても、受信データの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        logLine("  uri:" + uri);
    }

    public void onUpdateClick(View view) {

        logLine("[Update]");

        // ★ポイント4★ 同一アプリ内へのリクエストであるから、センシティブな情報をリクエストに含めてよい
        int count =
            getContentResolver().update(PrivateProvider.Download.CONTENT_URI, null, null, null);

        // ★ポイント5★ 同一アプリ内からの結果情報であっても、受信データの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        logLine(String.format("  %s records updated", count));
    }

    public void onDeleteClick(View view) {

        logLine("[Delete]");

        // ★ポイント4★ 同一アプリ内へのリクエストであるから、センシティブな情報をリクエストに含めてよい
        int count =
            getContentResolver().delete(PrivateProvider.Download.CONTENT_URI, null, null);

        // ★ポイント5★ 同一アプリ内からの結果情報であっても、受信データの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        logLine(String.format("  %s records deleted", count));
    }

    private TextView mLogView;

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

    private void logLine(String line) {
        mLogView.append(line);
        mLogView.append("\n");
    }
}

4.3.1.2. 公開Content Providerを作る・利用する

公開Content Providerは、不特定多数のアプリに利用されることを想定したContent Providerである。クライアントを限定しないことにより、マルウェアからselect()によって保持しているデータを抜き取られたり、update()によってデータを書き換えられたり、insert()/delete()によって偽のデータの挿入やデータの削除といった攻撃を受けたりして改ざんされ得ることに注意が必要だ。

また、Android OS既定ではない独自作成の公開Content Providerを利用する場合、その公開Content Providerに成り済ましたマルウェアにリクエストパラメータを受信されることがあること、および、攻撃結果データを受け取ることがあることに注意が必要である。Android OS既定のContactsやMediaStore等も公開Content Providerであるが、マルウェアはそれらContent Providerに成り済ましできない。

以下、公開Content Providerの実装例を示す。

ポイント(Content Providerを作る):

  1. exported="true"により、明示的に公開設定する
  2. リクエストパラメータの安全性を確認する
  3. センシティブな情報を返送してはならない
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.provider.publicprovider">

  <application
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name" >
    
    <!-- ★ポイント1★ exported="true"により、明示的に公開設定する -->
    <provider
        android:name=".PublicProvider"
        android:authorities="org.jssec.android.provider.publicprovider" 
        android:exported="true"/>
  </application>
</manifest>
PublicProvider.java
/*
 * Copyright (C) 2012-2021 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.provider.publicprovider;

import android.content.ContentProvider;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;

public class PublicProvider extends ContentProvider {

    public static final String AUTHORITY = "org.jssec.android.provider.publicprovider";
    public static final String CONTENT_TYPE =
        "vnd.android.cursor.dir/vnd.org.jssec.contenttype";
    public static final String CONTENT_ITEM_TYPE =
        "vnd.android.cursor.item/vnd.org.jssec.contenttype";

    // Content Providerが提供するインターフェースを公開
    public interface Download {
        public static final String PATH = "downloads";
        public static final Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITY + "/" + PATH);
    }
    public interface Address {
        public static final String PATH = "addresses";
        public static final Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITY + "/" + PATH);
    }

    // UriMatcher
    private static final int DOWNLOADS_CODE = 1;
    private static final int DOWNLOADS_ID_CODE = 2;
    private static final int ADDRESSES_CODE = 3;
    private static final int ADDRESSES_ID_CODE = 4;
    private static UriMatcher sUriMatcher;
    static {
        sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
        sUriMatcher.addURI(AUTHORITY, Download.PATH, DOWNLOADS_CODE);
        sUriMatcher.addURI(AUTHORITY, Download.PATH + "/#", DOWNLOADS_ID_CODE);
        sUriMatcher.addURI(AUTHORITY, Address.PATH, ADDRESSES_CODE);
        sUriMatcher.addURI(AUTHORITY, Address.PATH + "/#", ADDRESSES_ID_CODE);
    }

    // DBを使用せずに固定値を返す例にしているため、queryメソッドで返すCursorを事前に定義
    private static MatrixCursor sAddressCursor =
        new MatrixCursor(new String[] { "_id", "pref" });
    static {
        sAddressCursor.addRow(new String[] { "1", "北海道" });
        sAddressCursor.addRow(new String[] { "2", "青森" });
        sAddressCursor.addRow(new String[] { "3", "岩手" });
    }
    private static MatrixCursor sDownloadCursor =
        new MatrixCursor(new String[] { "_id", "path" });
    static {
        sDownloadCursor.addRow(new String[] { "1", "/sdcard/downloads/sample.jpg" });
        sDownloadCursor.addRow(new String[] { "2", "/sdcard/downloads/sample.txt" });
    }

    @Override
    public boolean onCreate() {
        return true;
    }

    @Override
    public String getType(Uri uri) {

        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
        case ADDRESSES_CODE:
            return CONTENT_TYPE;

        case DOWNLOADS_ID_CODE:
        case ADDRESSES_ID_CODE:
            return CONTENT_ITEM_TYPE;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {

        // ★ポイント2★ リクエストパラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // その他のパラメータの確認はサンプルにつき省略。「3.2 入力データの安全性を確認する」を参照。

        // ★ポイント3★ センシティブな情報を返送してはならない
        // queryの結果がセンシティブな意味を持つかどうかはアプリ次第。
        // リクエスト元のアプリがマルウェアである可能性がある。
        // マルウェアに取得されても問題のない情報であれば結果として返してもよい。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
        case DOWNLOADS_ID_CODE:
            return sDownloadCursor;

        case ADDRESSES_CODE:
        case ADDRESSES_ID_CODE:
            return sAddressCursor;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {

        // ★ポイント2★ リクエストパラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // その他のパラメータの確認はサンプルにつき省略。「3.2 入力データの安全性を確認する」を参照。

        // ★ポイント3★ センシティブな情報を返送してはならない
        // Insert結果、発番されるIDがセンシティブな意味を持つかどうかはアプリ次第。
        // リクエスト元のアプリがマルウェアである可能性がある。
        // マルウェアに取得されても問題のない情報であれば結果として返してもよい。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return ContentUris.withAppendedId(Download.CONTENT_URI, 3);

        case ADDRESSES_CODE:
            return ContentUris.withAppendedId(Address.CONTENT_URI, 4);

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection,
                      String[] selectionArgs) {

        // ★ポイント2★ リクエストパラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // その他のパラメータの確認はサンプルにつき省略。「3.2 入力データの安全性を確認する」を参照。

        // ★ポイント3★ センシティブな情報を返送してはならない
        // Updateされたレコード数がセンシティブな意味を持つかどうかはアプリ次第。
        // リクエスト元のアプリがマルウェアである可能性がある。
        // マルウェアに取得されても問題のない情報であれば結果として返してもよい。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return 5;       // updateされたレコード数を返す

        case DOWNLOADS_ID_CODE:
            return 1;

        case ADDRESSES_CODE:
            return 15;

        case ADDRESSES_ID_CODE:
            return 1;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {

        // ★ポイント2★ リクエストパラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // その他のパラメータの確認はサンプルにつき省略。「3.2 入力データの安全性を確認する」を参照。

        // ★ポイント3★ センシティブな情報を返送してはならない
        // Deleteされたレコード数がセンシティブな意味を持つかどうかはアプリ次第。
        // リクエスト元のアプリがマルウェアである可能性がある。
        // マルウェアに取得されても問題のない情報であれば結果として返してもよい。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return 10;      // deleteされたレコード数を返す

        case DOWNLOADS_ID_CODE:
            return 1;

        case ADDRESSES_CODE:
            return 20;

        case ADDRESSES_ID_CODE:
            return 1;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }
}

次に、公開Content Providerを利用するActivityの例を示す。

ポイント(Content Providerを利用する):

  1. センシティブな情報をリクエストに含めてはならない
  2. 結果データの安全性を確認する
PublicUserActivity.java
/*
 * Copyright (C) 2012-2021 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.provider.publicuser;

import android.app.Activity;
import android.content.ContentValues;
import android.content.pm.ProviderInfo;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;

public class PublicUserActivity extends Activity {

    // 利用先のContent Provider情報
    private static final String AUTHORITY = "org.jssec.android.provider.publicprovider";
    private interface Address {
        public static final String PATH = "addresses";
        public static final Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITY + "/" + PATH);
    }

    public void onQueryClick(View view) {

        logLine("[Query]");

        if (!providerExists(Address.CONTENT_URI)) {
            logLine("  Content Providerが不在");
            return;
        }

        // ★ポイント4★ センシティブな情報をリクエストに含めてはならない
        // リクエスト先のアプリがマルウェアである可能性がある。
        // マルウェアに取得されても問題のない情報であればリクエストに含めてもよい。
        Cursor cursor = null;
        try {
            cursor =
                getContentResolver().query(Address.CONTENT_URI, null, null, null, null);

            // ★ポイント5★ 結果データの安全性を確認する
            // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
            if (cursor == null) {
                logLine("  null cursor");
            } else {
                boolean moved = cursor.moveToFirst();
                while (moved) {
                    logLine(String.format("  %d, %s",
                                          cursor.getInt(0),
                                          cursor.getString(1)));
                    moved = cursor.moveToNext();
                }
            }
        }
        finally {
            if (cursor != null) cursor.close();
        }
    }

    public void onInsertClick(View view) {

        logLine("[Insert]");

        if (!providerExists(Address.CONTENT_URI)) {
            logLine("  Content Providerが不在");
            return;
        }

        // ★ポイント4★ センシティブな情報をリクエストに含めてはならない
        // リクエスト先のアプリがマルウェアである可能性がある。
        // マルウェアに取得されても問題のない情報であればリクエストに含めてもよい。
        ContentValues values = new ContentValues();
        values.put("pref", "東京都");
        Uri uri = getContentResolver().insert(Address.CONTENT_URI, values);

        // ★ポイント5★ 結果データの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        logLine("  uri:" + uri);
    }

    public void onUpdateClick(View view) {

        logLine("[Update]");

        if (!providerExists(Address.CONTENT_URI)) {
            logLine("  Content Providerが不在");
            return;
        }

        // ★ポイント4★ センシティブな情報をリクエストに含めてはならない
        // リクエスト先のアプリがマルウェアである可能性がある。
        // マルウェアに取得されても問題のない情報であればリクエストに含めてもよい。
        ContentValues values = new ContentValues();
        values.put("pref", "東京都");
        String where = "_id = ?";
        String[] args = { "4" };
        int count = getContentResolver().update(Address.CONTENT_URI, values, where, args);

        // ★ポイント5★ 結果データの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        logLine(String.format("  %s records updated", count));
    }

    public void onDeleteClick(View view) {

        logLine("[Delete]");

        if (!providerExists(Address.CONTENT_URI)) {
            logLine("  Content Providerが不在");
            return;
        }

        // ★ポイント4★ センシティブな情報をリクエストに含めてはならない
        // リクエスト先のアプリがマルウェアである可能性がある。
        // マルウェアに取得されても問題のない情報であればリクエストに含めてもよい。
        int count = getContentResolver().delete(Address.CONTENT_URI, null, null);

        // ★ポイント5★ 結果データの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        logLine(String.format("  %s records deleted", count));
    }

    private boolean providerExists(Uri uri) {
        ProviderInfo pi = getPackageManager().resolveContentProvider(uri.getAuthority(), 0);
        return (pi != null);
    }

    private TextView mLogView;

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

    private void logLine(String line) {
        mLogView.append(line);
        mLogView.append("\n");
    }
}

4.3.1.3. パートナー限定Content Providerを作る・利用する

パートナー限定Content Providerは、特定のアプリだけから利用できるContent Providerである。パートナー企業のアプリと自社アプリが連携してシステムを構成し、パートナーアプリとの間で扱う情報や機能を守るために利用される。

以下、パートナー限定Content Providerの実装例を示す。

ポイント(Content Providerを作る):

  1. exported="true"により、明示的に公開設定する
  2. 利用元アプリの証明書がホワイトリストに登録されていることを確認する
  3. パートナーアプリからのリクエストであっても、パラメータの安全性を確認する
  4. パートナーアプリに開示してよい情報に限り返送してよい
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.provider.partnerprovider">

  <application
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name" >
    
    <!-- ★ポイント1★ exported="true"により、明示的に公開設定する -->
    <provider
        android:name="org.jssec.android.provider.partnerprovider.PartnerProvider"
        android:authorities="org.jssec.android.provider.partnerprovider" 
        android:exported="true"/>
  </application>
</manifest>
PartnerProvider.java
/*
 * Copyright (C) 2012-2021 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.provider.partnerprovider;

import java.util.List;

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

import android.app.ActivityManager;
import android.app.ActivityManager.RunningAppProcessInfo;
import android.content.ContentProvider;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.Binder;
import android.os.Build;

public class PartnerProvider extends ContentProvider {

    public static final String AUTHORITY = "org.jssec.android.provider.partnerprovider";
    public static final String CONTENT_TYPE =
        "vnd.android.cursor.dir/vnd.org.jssec.contenttype";
    public static final String CONTENT_ITEM_TYPE =
        "vnd.android.cursor.item/vnd.org.jssec.contenttype";

    // Content Providerが提供するインターフェースを公開
    public interface Download {
        public static final String PATH = "downloads";
        public static final Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITY + "/" + PATH);
    }
    public interface Address {
        public static final String PATH = "addresses";
        public static final Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITY + "/" + PATH);
    }

    // UriMatcher
    private static final int DOWNLOADS_CODE = 1;
    private static final int DOWNLOADS_ID_CODE = 2;
    private static final int ADDRESSES_CODE = 3;
    private static final int ADDRESSES_ID_CODE = 4;
    private static UriMatcher sUriMatcher;
    static {
        sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
        sUriMatcher.addURI(AUTHORITY, Download.PATH, DOWNLOADS_CODE);
        sUriMatcher.addURI(AUTHORITY, Download.PATH + "/#", DOWNLOADS_ID_CODE);
        sUriMatcher.addURI(AUTHORITY, Address.PATH, ADDRESSES_CODE);
        sUriMatcher.addURI(AUTHORITY, Address.PATH + "/#", ADDRESSES_ID_CODE);
    }

    // DBを使用せずに固定値を返す例にしているため、queryメソッドで返すCursorを事前に定義
    private static MatrixCursor sAddressCursor =
        new MatrixCursor(new String[] { "_id", "pref" });
    static {
        sAddressCursor.addRow(new String[] { "1", "北海道" });
        sAddressCursor.addRow(new String[] { "2", "青森" });
        sAddressCursor.addRow(new String[] { "3", "岩手" });
    }
    private static MatrixCursor sDownloadCursor =
        new MatrixCursor(new String[] { "_id", "path" });
    static {
        sDownloadCursor.addRow(new String[] { "1", "/sdcard/downloads/sample.jpg" });
        sDownloadCursor.addRow(new String[] { "2", "/sdcard/downloads/sample.txt" });
    }

    // ★ポイント2★ 利用元アプリの証明書がホワイトリストに登録されていることを確認する
    private static PkgCertWhitelists sWhitelists = null;
    private static void buildWhitelists(Context context) {
        boolean isdebug = Utils.isDebuggable(context);
        sWhitelists = new PkgCertWhitelists();

        // パートナーアプリ org.jssec.android.provider.partneruser の証明書ハッシュ値を登録
        sWhitelists.add("org.jssec.android.provider.partneruser", isdebug ?
            // debug.keystoreの"androiddebugkey"の証明書ハッシュ値
            "0EFB7236 328348A9 89718BAD DF57F544 D5CCB4AE B9DB34BC 1E29DD26 F77C8255" :
            // keystoreの"partner key"の証明書ハッシュ値
            "1F039BB5 7861C27A 3916C778 8E78CE00 690B3974 3EB8259F E2627B8D 4C0EC35A");

        // 以下同様に他のパートナーアプリを登録...
    }
    private static boolean checkPartner(Context context, String pkgname) {
        if (sWhitelists == null) buildWhitelists(context);
        return sWhitelists.test(context, pkgname);
    }
    // 利用元アプリのパッケージ名を取得
    private String getCallingPackage(Context context) {
        String pkgname;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            pkgname = super.getCallingPackage();
        } else {
            pkgname = null;
            ActivityManager am =
                (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
            List<RunningAppProcessInfo> procList = am.getRunningAppProcesses();
            int callingPid = Binder.getCallingPid();
            if (procList != null) {
                for (RunningAppProcessInfo proc : procList) {
                    if (proc.pid == callingPid) {
                        pkgname = proc.pkgList[proc.pkgList.length - 1];
                        break;
                    }
                }
            }
        }

        return pkgname;
    }

    @Override
    public boolean onCreate() {
        return true;
    }

    @Override
    public String getType(Uri uri) {

        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
        case ADDRESSES_CODE:
            return CONTENT_TYPE;

        case DOWNLOADS_ID_CODE:
        case ADDRESSES_ID_CODE:
            return CONTENT_ITEM_TYPE;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {

        // ★ポイント2★ 利用元アプリの証明書がホワイトリストに登録されていることを確認する
        if (!checkPartner(getContext(), getCallingPackage(getContext()))) {
            throw new SecurityException("利用元アプリはパートナーアプリではない。");
        }

        // ★ポイント3★ パートナーアプリからのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // 「3.2 入力データの安全性を確認する」を参照。

        // ★ポイント4★ パートナーアプリに開示してよい情報に限り返送してよい
        // queryの結果がパートナーアプリに開示してよい情報かどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
        case DOWNLOADS_ID_CODE:
            return sDownloadCursor;

        case ADDRESSES_CODE:
        case ADDRESSES_ID_CODE:
            return sAddressCursor;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {

        // ★ポイント2★ 利用元アプリの証明書がホワイトリストに登録されていることを確認する
        if (!checkPartner(getContext(), getCallingPackage(getContext()))) {
            throw new SecurityException("利用元アプリはパートナーアプリではない。");
        }

        // ★ポイント3★ パートナーアプリからのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // 「3.2 入力データの安全性を確認する」を参照。

        // ★ポイント4★ パートナーアプリに開示してよい情報に限り返送してよい
        // Insert結果、発番されるIDがパートナーアプリに開示してよい情報かどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return ContentUris.withAppendedId(Download.CONTENT_URI, 3);

        case ADDRESSES_CODE:
            return ContentUris.withAppendedId(Address.CONTENT_URI, 4);

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection,
                      String[] selectionArgs) {

        // ★ポイント2★ 利用元アプリの証明書がホワイトリストに登録されていることを確認する
        if (!checkPartner(getContext(), getCallingPackage(getContext()))) {
            throw new SecurityException("利用元アプリはパートナーアプリではない。");
        }

        // ★ポイント3★ パートナーアプリからのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // 「3.2 入力データの安全性を確認する」を参照。

        // ★ポイント4★ パートナーアプリに開示してよい情報に限り返送してよい
        // Updateされたレコード数がセンシティブな意味を持つかどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return 5;       // updateされたレコード数を返す

        case DOWNLOADS_ID_CODE:
            return 1;

        case ADDRESSES_CODE:
            return 15;

        case ADDRESSES_ID_CODE:
            return 1;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {

        // ★ポイント2★ 利用元アプリの証明書がホワイトリストに登録されていることを確認する
        if (!checkPartner(getContext(), getCallingPackage(getContext()))) {
            throw new SecurityException("利用元アプリはパートナーアプリではない。");
        }

        // ★ポイント3★ パートナーアプリからのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // 「3.2 入力データの安全性を確認する」を参照。

        // ★ポイント4★ パートナーアプリに開示してよい情報に限り返送してよい
        // Deleteされたレコード数がセンシティブな意味を持つかどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return 10;      // deleteされたレコード数を返す

        case DOWNLOADS_ID_CODE:
            return 1;

        case ADDRESSES_CODE:
            return 20;

        case ADDRESSES_ID_CODE:
            return 1;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }
}

次に、パートナー限定Content Providerを利用するActivityの例を示す。

ポイント(Content Providerを利用する):

  1. 利用先パートナー限定Content Providerアプリの証明書がホワイトリストに登録されていることを確認する
  2. パートナー限定Content Providerアプリに開示してよい情報に限りリクエストに含めてよい
  3. パートナー限定Content Providerアプリからの結果であっても、結果データの安全性を確認する
PartnerUserActivity.java
/*
 * Copyright (C) 2012-2021 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.provider.partneruser;

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

import android.app.Activity;
import android.content.ContentValues;
import android.content.Context;
import android.content.pm.ProviderInfo;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;

public class PartnerUserActivity extends Activity {

    // 利用先のContent Provider情報
    private static final String AUTHORITY = "org.jssec.android.provider.partnerprovider";
    private interface Address {
        public static final String PATH = "addresses";
        public static final Uri CONTENT_URI =
                Uri.parse("content://" + AUTHORITY + "/" + PATH);
    }

    // ★ポイント5★ 利用先パートナー限定Content Providerアプリの証明書がホワイトリストに登録されている
    // ことを確認する
    private static PkgCertWhitelists sWhitelists = null;
    private static void buildWhitelists(Context context) {
        boolean isdebug = Utils.isDebuggable(context);
        sWhitelists = new PkgCertWhitelists();

        // パートナー限定Content Providerアプリ org.jssec.android.provider.partnerprovider の証明書
        // ハッシュ値を登録
        sWhitelists.add("org.jssec.android.provider.partnerprovider", isdebug ?
            // debug.keystoreの"androiddebugkey"の証明書ハッシュ値
            "0EFB7236 328348A9 89718BAD DF57F544 D5CCB4AE B9DB34BC 1E29DD26 F77C8255" :
            // keystoreの"my company key"の証明書ハッシュ値
            "D397D343 A5CBC10F 4EDDEB7C A10062DE 5690984F 1FB9E88B D7B3A7C2 42E142CA");

        // 以下同様に他のパートナー限定Content Providerアプリを登録...
    }
    private static boolean checkPartner(Context context, String pkgname) {
        if (sWhitelists == null) buildWhitelists(context);
        return sWhitelists.test(context, pkgname);
    }
    // uriをAUTHORITYとするContent Providerのパッケージ名を取得
    private String providerPkgname(Uri uri) {
        String pkgname = null;
        ProviderInfo pi =
            getPackageManager().resolveContentProvider(uri.getAuthority(), 0);
        if (pi != null) pkgname = pi.packageName;
        return pkgname;
    }

    public void onQueryClick(View view) {

        logLine("[Query]");

        // ★ポイント5★ 利用先パートナー限定Content Providerアプリの証明書がホワイトリストに登録されている
        // ことを確認する
        if (!checkPartner(this, providerPkgname(Address.CONTENT_URI))) {
            logLine("  利用先 Content Provider アプリはホワイトリストに登録されていない。");
            return;
        }

        // ★ポイント6★ パートナー限定Content Providerアプリに開示してよい情報に限りリクエストに含めてよい
        Cursor cursor = null;
        try {
            cursor =
                getContentResolver().query(Address.CONTENT_URI, null, null, null, null);

            // ★ポイント7★ パートナー限定Content Providerアプリからの結果であっても、結果データの安全性を
            // 確認する
            // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
            if (cursor == null) {
                logLine("  null cursor");
            } else {
                boolean moved = cursor.moveToFirst();
                while (moved) {
                    logLine(String.format("  %d, %s",
                                          cursor.getInt(0),
                                          cursor.getString(1)));
                    moved = cursor.moveToNext();
                }
            }
        }
        finally {
            if (cursor != null) cursor.close();
        }
    }

    public void onInsertClick(View view) {

        logLine("[Insert]");

        // ★ポイント5★ 利用先パートナー限定Content Providerアプリの証明書がホワイトリストに登録されている
        // ことを確認する
        if (!checkPartner(this, providerPkgname(Address.CONTENT_URI))) {
            logLine("  利用先 Content Provider アプリはホワイトリストに登録されていない。");
            return;
        }

        // ★ポイント6★ パートナー限定Content Providerアプリに開示してよい情報に限りリクエストに含めてよい
        ContentValues values = new ContentValues();
        values.put("pref", "東京都");
        Uri uri = getContentResolver().insert(Address.CONTENT_URI, values);

        // ★ポイント7★ パートナー限定Content Providerアプリからの結果であっても、結果データの安全性を
        // 確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        logLine("  uri:" + uri);
    }

    public void onUpdateClick(View view) {

        logLine("[Update]");

        // ★ポイント5★ 利用先パートナー限定Content Providerアプリの証明書がホワイトリストに登録されている
        // ことを確認する
        if (!checkPartner(this, providerPkgname(Address.CONTENT_URI))) {
            logLine("  利用先 Content Provider アプリはホワイトリストに登録されていない。");
            return;
        }

        // ★ポイント6★ パートナー限定Content Providerアプリに開示してよい情報に限りリクエストに含めてよい
        ContentValues values = new ContentValues();
        values.put("pref", "東京都");
        String where = "_id = ?";
        String[] args = { "4" };
        int count = getContentResolver().update(Address.CONTENT_URI, values, where, args);

        // ★ポイント7★ パートナー限定Content Providerアプリからの結果であっても、結果データの安全性を
        // 確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        logLine(String.format("  %s records updated", count));
    }

    public void onDeleteClick(View view) {

        logLine("[Delete]");

        // ★ポイント5★ 利用先パートナー限定Content Providerアプリの証明書がホワイトリストに登録されている
        // ことを確認する
        if (!checkPartner(this, providerPkgname(Address.CONTENT_URI))) {
            logLine("  利用先 Content Provider アプリはホワイトリストに登録されていない。");
            return;
        }

        // ★ポイント6★ パートナー限定Content Providerアプリに開示してよい情報に限りリクエストに含めてよい
        int count = getContentResolver().delete(Address.CONTENT_URI, null, null);

        // ★ポイント7★ パートナー限定Content Providerアプリからの結果であっても、結果データの安全性を
        // 確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        logLine(String.format("  %s records deleted", count));
    }

    private TextView mLogView;

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

    private void logLine(String line) {
        mLogView.append(line);
        mLogView.append("\n");
    }
}
PkgCertWhitelists.java
/*
 * Copyright (C) 2012-2021 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-2021 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();
    }
}

4.3.1.4. 自社限定Content Providerを作る・利用する

自社限定Content Providerは、自社以外のアプリから利用されることを禁止するContent Providerである。複数の自社製アプリでシステムを構成し、自社アプリが扱う情報や機能を守るために利用される。

以下、自社限定Content Providerの実装例を示す。

ポイント(Content Providerを作る):

  1. 独自定義Signature Permissionを定義する
  2. 独自定義Signature Permissionを要求宣言する
  3. exported="true"により、明示的に公開設定する
  4. 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
  5. 自社アプリからのリクエストであっても、パラメータの安全性を確認する
  6. 利用元アプリは自社アプリであるから、センシティブな情報を返送してよい
  7. APKをExportするときに、利用元アプリと同じ開発者鍵でAPKを署名する
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.provider.inhouseprovider">

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

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

    <!-- ★ポイント2★ 独自定義Signature Permissionを要求宣言する -->
    <!-- ★ポイント3★ exported="true"により、明示的に公開設定する -->
    <provider
        android:name="org.jssec.android.provider.inhouseprovider.InhouseProvider"
        android:authorities="org.jssec.android.provider.inhouseprovider"
        android:permission="org.jssec.android.provider.inhouseprovider.MY_PERMISSION" 
        android:exported="true"/>
  </application>
</manifest>
InhouseProvider.java
/*
 * Copyright (C) 2012-2021 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.provider.inhouseprovider;

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

import android.content.ContentProvider;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;

public class InhouseProvider extends ContentProvider {

    public static final String AUTHORITY =
        "org.jssec.android.provider.inhouseprovider";
    public static final String CONTENT_TYPE =
        "vnd.android.cursor.dir/vnd.org.jssec.contenttype";
    public static final String CONTENT_ITEM_TYPE =
        "vnd.android.cursor.item/vnd.org.jssec.contenttype";

    // Content Providerが提供するインターフェースを公開
    public interface Download {
        public static final String PATH = "downloads";
        public static final Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITY + "/" + PATH);
    }
    public interface Address {
        public static final String PATH = "addresses";
        public static final Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITY + "/" + PATH);
    }

    // UriMatcher
    private static final int DOWNLOADS_CODE = 1;
    private static final int DOWNLOADS_ID_CODE = 2;
    private static final int ADDRESSES_CODE = 3;
    private static final int ADDRESSES_ID_CODE = 4;
    private static UriMatcher sUriMatcher;
    static {
        sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
        sUriMatcher.addURI(AUTHORITY, Download.PATH, DOWNLOADS_CODE);
        sUriMatcher.addURI(AUTHORITY, Download.PATH + "/#", DOWNLOADS_ID_CODE);
        sUriMatcher.addURI(AUTHORITY, Address.PATH, ADDRESSES_CODE);
        sUriMatcher.addURI(AUTHORITY, Address.PATH + "/#", ADDRESSES_ID_CODE);
    }

    // DBを使用せずに固定値を返す例にしているため、queryメソッドで返すCursorを事前に定義
    private static MatrixCursor sAddressCursor =
        new MatrixCursor(new String[] { "_id", "pref" });
    static {
        sAddressCursor.addRow(new String[] { "1", "北海道" });
        sAddressCursor.addRow(new String[] { "2", "青森" });
        sAddressCursor.addRow(new String[] { "3", "岩手" });
    }
    private static MatrixCursor sDownloadCursor =
        new MatrixCursor(new String[] { "_id", "path" });
    static {
        sDownloadCursor.addRow(new String[] { "1", "/sdcard/downloads/sample.jpg" });
        sDownloadCursor.addRow(new String[] { "2", "/sdcard/downloads/sample.txt" });
    }

    // 自社のSignature Permission
    private static final String MY_PERMISSION =
        "org.jssec.android.provider.inhouseprovider.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 boolean onCreate() {
        return true;
    }

    @Override
    public String getType(Uri uri) {

        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
        case ADDRESSES_CODE:
            return CONTENT_TYPE;

        case DOWNLOADS_ID_CODE:
        case ADDRESSES_ID_CODE:
            return CONTENT_ITEM_TYPE;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {

        // ★ポイント4★ 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
        if (!SigPerm.test(getContext(), MY_PERMISSION, myCertHash(getContext()))) {
            throw new SecurityException("独自定義Signature Permissionが自社アプリにより定義されていない。");
        }

        // ★ポイント5★ 自社アプリからのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // 「3.2 入力データの安全性を確認する」を参照。
        // ★ポイント6★ 利用元アプリは自社アプリであるから、センシティブな情報を返送してよい
        // queryの結果が自社アプリに開示してよい情報かどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
        case DOWNLOADS_ID_CODE:
            return sDownloadCursor;

        case ADDRESSES_CODE:
        case ADDRESSES_ID_CODE:
            return sAddressCursor;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {

        // ★ポイント4★ 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
        if (!SigPerm.test(getContext(), MY_PERMISSION, myCertHash(getContext()))) {
            throw new SecurityException("独自定義Signature Permissionが自社アプリにより定義されていない。");
        }

        // ★ポイント5★ 自社アプリからのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // 「3.2 入力データの安全性を確認する」を参照。
        // ★ポイント6★ 利用元アプリは自社アプリであるから、センシティブな情報を返送してよい
        // Insert結果、発番されるIDが自社アプリに開示してよい情報かどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return ContentUris.withAppendedId(Download.CONTENT_URI, 3);

        case ADDRESSES_CODE:
            return ContentUris.withAppendedId(Address.CONTENT_URI, 4);

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection,
                      String[] selectionArgs) {

        // ★ポイント4★ 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
        if (!SigPerm.test(getContext(), MY_PERMISSION, myCertHash(getContext()))) {
            throw new SecurityException("独自定義Signature Permissionが自社アプリにより定義されていない。");
        }

        // ★ポイント5★ 自社アプリからのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // 「3.2 入力データの安全性を確認する」を参照。
        // ★ポイント6★ 利用元アプリは自社アプリであるから、センシティブな情報を返送してよい
        // Updateされたレコード数がセンシティブな意味を持つかどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return 5;   // updateされたレコード数を返す

        case DOWNLOADS_ID_CODE:
            return 1;

        case ADDRESSES_CODE:
            return 15;

        case ADDRESSES_ID_CODE:
            return 1;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {

        // ★ポイント4★ 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
        if (!SigPerm.test(getContext(), MY_PERMISSION, myCertHash(getContext()))) {
            throw new SecurityException("独自定義Signature Permissionが自社アプリにより定義されていない。");
        }

        // ★ポイント5★ 自社アプリからのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // 「3.2 入力データの安全性を確認する」を参照。
        // ★ポイント6★ 利用元アプリは自社アプリであるから、センシティブな情報を返送してよい
        // Deleteされたレコード数がセンシティブな意味を持つかどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return 10;  // deleteされたレコード数を返す

        case DOWNLOADS_ID_CODE:
            return 1;

        case ADDRESSES_CODE:
            return 20;

        case ADDRESSES_ID_CODE:
            return 1;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }
}
SigPerm.java
/*
 * Copyright (C) 2012-2021 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;
        }
    }
}
PkgCert.java
/*
 * Copyright (C) 2012-2021 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();
    }
}

★ポイント7★ APKをExportするときに、利用元アプリと同じ開発者鍵でAPKを署名する。

_images/image34.png

図 4.3.2 利用元アプリと同じ開発者鍵で APK を署名する

次に、自社限定Content Providerを利用するActivityの例を示す。

ポイント(Content Providerを利用する):

  1. 独自定義Signature Permissionを利用宣言する
  2. 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
  3. 利用先Content Providerアプリの証明書が自社の証明書であることを確認する
  4. 自社限定Content Providerアプリに開示してよい情報に限りリクエストに含めてよい
  5. 自社限定Content Providerアプリからの結果であっても、結果データの安全性を確認する
  6. APKをExportするときに、利用先アプリと同じ開発者鍵でAPKを署名する
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.provider.inhouseuser">

  <queries>
    <package android:name="org.jssec.android.provider.inhouseprovider" />
  </queries>
  
  <!-- ★ポイント8★ 独自定義Signature Permissionを利用宣言する -->
  <uses-permission
      android:name="org.jssec.android.provider.inhouseprovider.MY_PERMISSION" />

  <application
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name" >
    <activity
        android:name="org.jssec.android.provider.inhouseuser.InhouseUserActivity"
        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>
InhouseUserActivity.java
/*
 * Copyright (C) 2012-2021 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.provider.inhouseuser;

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.ContentValues;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.pm.ProviderInfo;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;

public class InhouseUserActivity extends Activity {

    // 利用先のContent Provider情報
    private static final String AUTHORITY = "org.jssec.android.provider.inhouseprovider";
    private interface Address {
        public static final String PATH = "addresses";
        public static final Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITY + "/" + PATH);
    }

    // 自社のSignature Permission
    private static final String MY_PERMISSION =
        "org.jssec.android.provider.inhouseprovider.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;
    }

    // 利用先Content Providerのパッケージ名を取得
    private static String providerPkgname(Context context, Uri uri) {
        String pkgname = null;
        PackageManager pm = context.getPackageManager();
        ProviderInfo pi = pm.resolveContentProvider(uri.getAuthority(), 0);
        if (pi != null) pkgname = pi.packageName;
        return pkgname;
    }

    public void onQueryClick(View view) {

        logLine("[Query]");

        // ★ポイント9★ 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
        if (!SigPerm.test(this, MY_PERMISSION, myCertHash(this))) {
            logLine("  独自定義Signature Permissionが自社アプリにより定義されていない。");
            return;
        }

        // ★ポイント10★ 利用先Content Providerアプリの証明書が自社の証明書であることを確認する
        String pkgname = providerPkgname(this, Address.CONTENT_URI);
        if (!PkgCert.test(this, pkgname, myCertHash(this))) {
            logLine("  利用先 Content Provider は自社アプリではない。");
            return;
        }

        // ★ポイント11★ 自社限定Content Providerアプリに開示してよい情報に限りリクエストに含めてよい
        Cursor cursor = null;
        try {
            cursor =
                getContentResolver().query(Address.CONTENT_URI, null, null, null, null);

            // ★ポイント12★ 自社限定Content Providerアプリからの結果であっても、結果データの安全性を確認する
            // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
            if (cursor == null) {
                logLine("  null cursor");
            } else {
                boolean moved = cursor.moveToFirst();
                while (moved) {
                    logLine(String.format("  %d, %s",
                                          cursor.getInt(0),
                                          cursor.getString(1)));
                    moved = cursor.moveToNext();
                }
            }
        }
        finally {
            if (cursor != null) cursor.close();
        }
    }

    public void onInsertClick(View view) {

        logLine("[Insert]");

        // ★ポイント9★ 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
        String correctHash = myCertHash(this);
        if (!SigPerm.test(this, MY_PERMISSION, correctHash)) {
            logLine("  独自定義Signature Permissionが自社アプリにより定義されていない。");
            return;
        }

        // ★ポイント10★ 利用先Content Providerアプリの証明書が自社の証明書であることを確認する
        String pkgname = providerPkgname(this, Address.CONTENT_URI);
        if (!PkgCert.test(this, pkgname, correctHash)) {
            logLine("  利用先 Content Provider は自社アプリではない。");
            return;
        }

        // ★ポイント11★ 自社限定Content Providerアプリに開示してよい情報に限りリクエストに含めてよい
        ContentValues values = new ContentValues();
        values.put("pref", "東京都");
        Uri uri = getContentResolver().insert(Address.CONTENT_URI, values);

        // ★ポイント12★ 自社限定Content Providerアプリからの結果であっても、結果データの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        logLine("  uri:" + uri);
    }

    public void onUpdateClick(View view) {

        logLine("[Update]");

        // ★ポイント9★ 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
        String correctHash = myCertHash(this);
        if (!SigPerm.test(this, MY_PERMISSION, correctHash)) {
            logLine("  独自定義Signature Permissionが自社アプリにより定義されていない。");
            return;
        }

        // ★ポイント10★ 利用先Content Providerアプリの証明書が自社の証明書であることを確認する
        String pkgname = providerPkgname(this, Address.CONTENT_URI);
        if (!PkgCert.test(this, pkgname, correctHash)) {
            logLine("  利用先 Content Provider は自社アプリではない。");
            return;
        }

        // ★ポイント11★ 自社限定Content Providerアプリに開示してよい情報に限りリクエストに含めてよい
        ContentValues values = new ContentValues();
        values.put("pref", "東京都");
        String where = "_id = ?";
        String[] args = { "4" };
        int count = getContentResolver().update(Address.CONTENT_URI, values, where, args);

        // ★ポイント12★ 自社限定Content Providerアプリからの結果であっても、結果データの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        logLine(String.format("  %s records updated", count));
    }

    public void onDeleteClick(View view) {

        logLine("[Delete]");

        // ★ポイント9★ 独自定義Signature Permissionが自社アプリにより定義されていることを確認する
        String correctHash = myCertHash(this);
        if (!SigPerm.test(this, MY_PERMISSION, correctHash)) {
            logLine("  独自定義Signature Permissionが自社アプリにより定義されていない。");
            return;
        }

        // ★ポイント10★ 利用先Content Providerアプリの証明書が自社の証明書であることを確認する
        String pkgname = providerPkgname(this, Address.CONTENT_URI);
        if (!PkgCert.test(this, pkgname, correctHash)) {
            logLine("  利用先 Content Provider は自社アプリではない。");
            return;
        }

        // ★ポイント11★ 自社限定Content Providerアプリに開示してよい情報に限りリクエストに含めてよい
        int count = getContentResolver().delete(Address.CONTENT_URI, null, null);

        // ★ポイント12★ 自社限定Content Providerアプリからの結果であっても、結果データの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        logLine(String.format("  %s records deleted", count));
    }

    private TextView mLogView;

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

    private void logLine(String line) {
        mLogView.append(line);
        mLogView.append("\n");
    }
}
SigPerm.java
/*
 * Copyright (C) 2012-2021 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;
        }
    }
}
PkgCert.java
/*
 * Copyright (C) 2012-2021 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();
    }
}

★ポイント13★APKをExportするときに、利用先アプリと同じ開発者鍵でAPKを署名する。

_images/image34.png

図 4.3.3 利用先アプリと同じ開発者鍵で APK を署名する

4.3.1.5. 一時許可Content Providerを作る・利用する

一時許可Content Providerは、基本的には非公開のContent Providerであるが、特定のアプリに対して一時的に特定URIへのアクセスを許可するContent Providerである。特殊なフラグを指定したIntentを対象アプリに送付することにより、そのアプリに一時的なアクセス権限が付されるようになっている。Content Provider側アプリが能動的に他のアプリにアクセス許可を与えることもできるし、一時的なアクセス許可を求めてきたアプリにContent Provider側アプリが受動的にアクセス許可を与えることもできる。

以下、一時許可Content Providerの実装例を示す。

ポイント(Content Providerを作る):

  1. exported="false"により、一時許可するPath以外を非公開設定する
  2. grant-uri-permissionにより、一時許可するPathを指定する
  3. 一時的に許可したアプリからのリクエストであっても、パラメータの安全性を確認する
  4. 一時的に許可したアプリに開示してよい情報に限り返送してよい
  5. 一時的にアクセスを許可するURIをIntentに指定する
  6. 一時的に許可するアクセス権限をIntentに指定する
  7. 一時的にアクセスを許可するアプリに明示的Intentを送信する
  8. 一時許可の要求元アプリにIntentを返信する
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.provider.temporaryprovider">

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

    <activity
        android:name=".TemporaryActiveGrantActivity"
        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>

    <!-- 一時許可Content Provider -->
    <!-- ★ポイント1★ exported="false"により、一時許可するPath以外を非公開設定する -->
    <provider
        android:name=".TemporaryProvider"
        android:authorities="org.jssec.android.provider.temporaryprovider"
        android:exported="false" >

      <!-- ★ポイント2★ grant-uri-permissionにより、一時許可するPathを指定する -->
      <grant-uri-permission android:path="/addresses" />

    </provider>

    <activity
        android:name=".TemporaryPassiveGrantActivity"
        android:label="@string/app_name"
        android:exported="true" />
  </application>
</manifest>
TemporaryProvider.java
/*
 * Copyright (C) 2012-2021 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.provider.temporaryprovider;

import android.content.ContentProvider;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;

public class TemporaryProvider extends ContentProvider {
    public static final String AUTHORITIY =
        "org.jssec.android.provider.temporaryprovider";
    public static final String CONTENT_TYPE =
        "vnd.android.cursor.dir/vnd.org.jssec.contenttype";
    public static final String CONTENT_ITEM_TYPE =
        "vnd.android.cursor.item/vnd.org.jssec.contenttype";

    // Content Providerが提供するインターフェースを公開
    public interface Download {
        public static final String PATH = "downloads";
        public static final Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITIY + "/" + PATH);
    }
    public interface Address {
        public static final String PATH = "addresses";
        public static final Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITIY + "/" + PATH);
    }

    // UriMatcher
    private static final int DOWNLOADS_CODE = 1;
    private static final int DOWNLOADS_ID_CODE = 2;
    private static final int ADDRESSES_CODE = 3;
    private static final int ADDRESSES_ID_CODE = 4;
    private static UriMatcher sUriMatcher;
    static {
        sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
        sUriMatcher.addURI(AUTHORITIY, Download.PATH, DOWNLOADS_CODE);
        sUriMatcher.addURI(AUTHORITIY, Download.PATH + "/#", DOWNLOADS_ID_CODE);
        sUriMatcher.addURI(AUTHORITIY, Address.PATH, ADDRESSES_CODE);
        sUriMatcher.addURI(AUTHORITIY, Address.PATH + "/#", ADDRESSES_ID_CODE);
    }

    // DBを使用せずに固定値を返す例にしているため、queryメソッドで返すCursorを事前に定義
    private static MatrixCursor sAddressCursor =
        new MatrixCursor(new String[] { "_id", "pref" });
    static {
        sAddressCursor.addRow(new String[] { "1", "北海道" });
        sAddressCursor.addRow(new String[] { "2", "青森" });
        sAddressCursor.addRow(new String[] { "3", "岩手" });
    }
    private static MatrixCursor sDownloadCursor =
        new MatrixCursor(new String[] { "_id", "path" });
    static {
        sDownloadCursor.addRow(new String[] { "1", "/sdcard/downloads/sample.jpg" });
        sDownloadCursor.addRow(new String[] { "2", "/sdcard/downloads/sample.txt" });
    }

    @Override
    public boolean onCreate() {
        return true;
    }

    @Override
    public String getType(Uri uri) {

        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
        case ADDRESSES_CODE:
            return CONTENT_TYPE;

        case DOWNLOADS_ID_CODE:
        case ADDRESSES_ID_CODE:
            return CONTENT_ITEM_TYPE;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {

        // ★ポイント3★ 一時的に許可したアプリからのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // その他のパラメータの確認はサンプルにつき省略。「3.2 入力データの安全性を確認する」を参照。
        // ★ポイント4★ 一時的に許可したアプリに開示してよい情報に限り返送してよい
        // queryの結果がセンシティブな意味を持つかどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
        case DOWNLOADS_ID_CODE:
            return sDownloadCursor;

        case ADDRESSES_CODE:
        case ADDRESSES_ID_CODE:
            return sAddressCursor;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {

        // ★ポイント3★ 一時的に許可したアプリからのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // その他のパラメータの確認はサンプルにつき省略。「3.2 入力データの安全性を確認する」を参照。
        // ★ポイント4★ 一時的に許可したアプリに開示してよい情報に限り返送してよい
        // Insert結果、発番されるIDがセンシティブな意味を持つかどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return ContentUris.withAppendedId(Download.CONTENT_URI, 3);

        case ADDRESSES_CODE:
            return ContentUris.withAppendedId(Address.CONTENT_URI, 4);

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection,
                      String[] selectionArgs) {

        // ★ポイント3★ 一時的に許可したアプリからのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // その他のパラメータの確認はサンプルにつき省略。「3.2 入力データの安全性を確認する」を参照。
        // ★ポイント4★ 一時的に許可したアプリに開示してよい情報に限り返送してよい
        // Updateされたレコード数がセンシティブな意味を持つかどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return 5;   // updateされたレコード数を返す

        case DOWNLOADS_ID_CODE:
            return 1;

        case ADDRESSES_CODE:
            return 15;

        case ADDRESSES_ID_CODE:
            return 1;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {

        // ★ポイント3★ 一時的に許可したアプリからのリクエストであっても、パラメータの安全性を確認する
        // ここではuriが想定の範囲内であることを、UriMatcher#match()とswitch caseで確認している。
        // その他のパラメータの確認はサンプルにつき省略。「3.2 入力データの安全性を確認する」を参照。
        // ★ポイント4★ 一時的に許可したアプリに開示してよい情報に限り返送してよい
        // Deleteされたレコード数がセンシティブな意味を持つかどうかはアプリ次第。
        switch (sUriMatcher.match(uri)) {
        case DOWNLOADS_CODE:
            return 10;  // deleteされたレコード数を返す

        case DOWNLOADS_ID_CODE:
            return 1;

        case ADDRESSES_CODE:
            return 20;

        case ADDRESSES_ID_CODE:
            return 1;

        default:
            throw new IllegalArgumentException("Invalid URI:" + uri);
        }
    }
}
TemporaryActiveGrantActivity.java
/*
 * Copyright (C) 2012-2021 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.provider.temporaryprovider;

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

public class TemporaryActiveGrantActivity extends Activity {

    // User Activityに関する情報
    private static final String TARGET_PACKAGE =
        "org.jssec.android.provider.temporaryuser";
    private static final String TARGET_ACTIVITY =
        "org.jssec.android.provider.temporaryuser.TemporaryUserActivity";

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

    // Content Provider側アプリが能動的に他のアプリにアクセス許可を与えるケース
    public void onSendClick(View view) {
        try {
            Intent intent = new Intent();

            // ★ポイント5★ 一時的にアクセスを許可するURIをIntentに指定する
            intent.setData(TemporaryProvider.Address.CONTENT_URI);

            // ★ポイント6★ 一時的に許可するアクセス権限をIntentに指定する
            intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);

            // ★ポイント7★ 一時的にアクセスを許可するアプリに明示的Intentを送信する
            intent.setClassName(TARGET_PACKAGE, TARGET_ACTIVITY);
            startActivity(intent);

        } catch (ActivityNotFoundException e) {
            Toast.makeText(this, "User Activityが見つからない。", Toast.LENGTH_LONG).show();
        }
    }
}
TemporaryPassiveGrantActivity.java
/*
 * Copyright (C) 2012-2021 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.provider.temporaryprovider;

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

public class TemporaryPassiveGrantActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.passive_grant);
    }

    // 一時的なアクセス許可を求めてきたアプリにContent Provider側アプリが受動的にアクセス許可を与えるケース
    public void onGrantClick(View view) {
        Intent intent = new Intent();

        // ★ポイント5★ 一時的にアクセスを許可するURIをIntentに指定する
        intent.setData(TemporaryProvider.Address.CONTENT_URI);

        // ★ポイント6★ 一時的に許可するアクセス権限をIntentに指定する
        intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);

        // ★ポイント8★ 一時許可の要求元アプリにIntentを返信する
        setResult(Activity.RESULT_OK, intent);
        finish();
    }

    public void onCloseClick(View view) {
        finish();
    }
}

次に、一時許可Content Providerを利用するActivityの例を示す。

ポイント(Content Providerを利用する):

  1. センシティブな情報をリクエストに含めてはならない
  2. 結果データの安全性を確認する
TemporaryUserActivity.java
/*
 * Copyright (C) 2012-2021 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.provider.temporaryuser;

import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.pm.ProviderInfo;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;

public class TemporaryUserActivity extends Activity {

    // Provider Activityに関する情報
    private static final String TARGET_PACKAGE =
        "org.jssec.android.provider.temporaryprovider";
    private static final String TARGET_ACTIVITY =
        "org.jssec.android.provider.temporaryprovider.TemporaryPassiveGrantActivity";

    // 利用先のContent Provider情報
    private static final String AUTHORITY = "org.jssec.android.provider.temporaryprovider";
    private interface Address {
        public static final String PATH = "addresses";
        public static final Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITY + "/" + PATH);
    }

    private static final int REQUEST_CODE = 1;

    public void onQueryClick(View view) {

        logLine("[Query]");

        Cursor cursor = null;
        try {
            if (!providerExists(Address.CONTENT_URI)) {
                logLine("  Content Providerが不在");
                return;
            }

            // ★ポイント9★ センシティブな情報をリクエストに含めてはならない
            // リクエスト先のアプリがマルウェアである可能性がある。
            // マルウェアに取得されても問題のない情報であればリクエストに含めてもよい。
            cursor = getContentResolver()
                    .query(Address.CONTENT_URI, null, null, null, null);

            // ★ポイント10★ 結果データの安全性を確認する
            // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
            if (cursor == null) {
                logLine("  null cursor");
            } else {
                boolean moved = cursor.moveToFirst();
                while (moved) {
                    logLine(String.format("  %d, %s",
                                          cursor.getInt(0),
                                          cursor.getString(1)));
                    moved = cursor.moveToNext();
                }
            }
        } catch (SecurityException ex) {
            logLine("  例外:" + ex.getMessage());
        }
        finally {
            if (cursor != null) cursor.close();
        }
    }

    // このアプリが一時的なアクセス許可を要求し、Content Provider側アプリが受動的にアクセス許可を与えるケース
    public void onGrantRequestClick(View view) {
        Intent intent = new Intent();
        intent.setClassName(TARGET_PACKAGE, TARGET_ACTIVITY);
        try {
            startActivityForResult(intent, REQUEST_CODE);
        } catch (ActivityNotFoundException e) {
            logLine("Grantの要求に失敗しました。\nTemporaryProviderがインストールされているか確認してください。");
        }
    }

    private boolean providerExists(Uri uri) {
        ProviderInfo pi =
            getPackageManager().resolveContentProvider(uri.getAuthority(), 0);
        return (pi != null);
    }

    private TextView mLogView;

    // Content Provider側アプリが能動的にこのアプリにアクセス許可を与えるケース
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        mLogView = (TextView)findViewById(R.id.logview);
    }

    private void logLine(String line) {
        mLogView.append(line);
        mLogView.append("\n");
    }
}

4.3.2. ルールブック

Content Providerの実装時には以下のルールを守ること。

  1. アプリ内でのみ使用するContent Providerは非公開設定する (必須)
  2. リクエストパラメータの安全性を確認する (必須)
  3. 独自定義Signature Permissionは、自社アプリが定義したことを確認して利用する (必須)
  4. 結果情報を返す場合には、返送先アプリからの結果情報漏洩に注意する (必須)
  5. 資産を二次的に提供する場合には、その資産の従来の保護水準を維持する (必須)

また、利用側は、以下のルールも守ること。

  1. Content Providerの結果データの安全性を確認する (必須)

4.3.2.1. アプリ内でのみ使用するContent Providerは非公開設定する (必須)

同一アプリ内からのみ利用されるContent Providerは他のアプリからアクセスできる必要がないだけでなく、開発者もContent Providerを攻撃するアクセスを考慮しないことが多い。Content Providerはデータ共有するための仕組みであるため、デフォルトでは公開扱いになってしまう。同一アプリ内からのみ利用されるContent Providerは明示的に非公開設定し、非公開Content Providerとすべきである。

AndroidManifest.xml
    <!-- ★4.3.1.1 - ポイント1★ exported="false"により、明示的に非公開設定する -->
    <provider
        android:name=".PrivateProvider"
        android:authorities="org.jssec.android.provider.privateprovider"
        android:exported="false" />

4.3.2.2. リクエストパラメータの安全性を確認する (必須)

Content Providerのタイプによって若干リスクは異なるが、リクエストパラメータを処理する際には、まずその安全性を確認しなければならない。

Content Providerの各メソッドはSQL文の構成要素パラメータを受け取ることを想定したインターフェースになってはいるものの、仕組みの上では単に任意の文字列を受け渡すだけのものであり、Content Provider側では想定外のパラメータが与えられるケースを想定しなければならないことに注意が必要だ。

公開Content Providerは不特定多数のアプリからリクエストを受け取るため、マルウェアの攻撃リクエストを受け取る可能性がある。非公開Content Providerは他のアプリからリクエストを直接受け取ることはない。しかし同一アプリ内の公開Activityが他のアプリから受け取ったIntentのデータを非公開Content Providerに転送するといったケースも考えられるため、リクエストを無条件に安全であると考えてはならない。その他のContent Providerについても、やはりリクエストの安全性を確認する必要がある。

3.2. 入力データの安全性を確認する」を参照すること。

4.3.2.3. 独自定義Signature Permissionは、自社アプリが定義したことを確認して利用する (必須)

自社アプリだけから利用できる自社限定Content Providerを作る場合、独自定義Signature Permissionにより保護しなければならない。AndroidManifest.xmlでのPermission定義、Permission要求宣言だけでは保護が不十分であるため、「5.2. PermissionとProtection Level」の「5.2.1.2. 独自定義のSignature Permissionで自社アプリ連携する方法」を参照すること。

4.3.2.4. 結果情報を返す場合には、返送先アプリからの結果情報漏洩に注意する (必須)

query()やinsert()ではリクエスト要求元アプリに結果情報としてCursorやUriが返送される。結果情報にセンシティブな情報が含まれる場合、返送先アプリから情報漏洩する可能性がある。またupdate()やdelete()では更新または削除されたレコード数がリクエスト要求元アプリに結果情報として返送される。まれにアプリ仕様によっては更新または削除されたレコード数がセンシティブな意味を持つ場合があるので注意すべきだ。

4.3.2.5. 資産を二次的に提供する場合には、その資産の従来の保護水準を維持する (必須)

Permissionにより保護されている情報資産および機能資産を他のアプリに二次的に提供する場合には、提供先アプリに対して同一のPermissionを要求するなどして、その保護水準を維持しなければならない。AndroidのPermissionセキュリティモデルでは、保護された資産に対するアプリからの直接アクセスについてのみ権限管理を行う。この仕様上の特性により、アプリに取得された資産がさらに他のアプリに、保護のために必要なPermissionを要求することなく提供される可能性がある。このことはPermissionを再委譲していることと実質的に等価なので、Permissionの再委譲問題と呼ばれる。「5.2.3.4. Permissionの再委譲問題」を参照すること。

4.3.2.6. Content Providerの結果データの安全性を確認する (必須)

Content Providerのタイプによって若干リスクは異なるが、結果データを処理する際には、まず結果データの安全性を確認しなければならない。

利用先Content Providerが公開Content Providerの場合、公開Content Providerに成り済ましたマルウェアが攻撃結果データを返送してくる可能性がある。利用先Content Providerが非公開Content Providerの場合、同一アプリ内から結果データを受け取るのでリスクは少ないが、結果データを無条件に安全であると考えてはならない。その他のContent Providerについても、やはり結果データの安全性を確認する必要がある。

3.2. 入力データの安全性を確認する」を参照すること。

4.4. Serviceを作る・利用する

4.4.1. サンプルコード

Serviceがどのように利用されるかによって、Serviceが抱えるリスクや適切な防御手段が異なる。次の判定フローによって作成するServiceがどのタイプであるかを判断できる。なお、作成するServiceのタイプによってServiceを利用する側の実装も決まるので、利用側の実装についても合わせて説明する。

_images/image43.png

図 4.4.1 Service タイプの選択フロー

Serviceには複数の実装方法があり、その中から作成するServiceのタイプに合った方法を選択することになる。下表の縦の項目が本文書で扱う実装方法であり、5種類に分類した。表中のo印は実現可能な組み合わせを示し、その他は実現不可能もしくは困難なものを示す。

なお、Serviceの実装方法の詳細については、「4.4.3.2. Serviceの実装方法について」および各Serviceタイプのサンプルコード(表中で*印の付いたもの)を参照すること。

表 4.4.1 Service の実装方法
分類 非公開Service 公開Service パートナー限定Service 自社限定Service
startService型 o* o - o
IntentService型 o o* - o
local bind型 o - - -
Messenger bind型 o o - o*
AIDL bind型 o o o* o

以下では 表 4.4.1 中の*印の組み合わせを使って各セキュリティタイプのServiceのサンプルコードを示す。

4.4.1.1. 非公開Serviceを作る・利用する

非公開Serviceは、同一アプリ内でのみ利用されるServiceであり、もっとも安全性の高いServiceである。

また、非公開Serviceを利用するには、クラスを指定する明示的Intentを使えば誤って外部アプリにIntentを送信してしまうことがない。

以下、startService型のServiceを使用した例を示す。

ポイント(Serviceを作る):

  1. exported="false"により、明示的に非公開設定する
  2. 同一アプリからのIntentであっても、受信Intentの安全性を確認する
  3. 結果を返す場合、利用元アプリは同一アプリであるから、センシティブな情報を返送してよい
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.service.privateservice" >

  <application
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name"
      android:allowBackup="false">
    <activity
        android:name=".PrivateUserActivity"
        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>
    
    <!-- 非公開Service -->
    <!-- ★ポイント1★ exported="false"により、明示的に非公開設定する -->
    <service android:name=".PrivateStartService" android:exported="false"/>
    
    <!-- IntentServiceを継承したService -->
    <!-- 非公開Service -->
    <!-- ★ポイント1★ exported="false"により、明示的に非公開設定する -->
    <service android:name=".PrivateIntentService" android:exported="false"/>
    
  </application>

</manifest>
PrivateStartService.java
/*
 * Copyright (C) 2012-2021 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.service.privateservice;

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

public class PrivateStartService extends Service{    
    // Serviceが起動するときに1回だけ呼び出される
    @Override
    public void onCreate() {       
        Toast.makeText(this,
                       this.getClass().getSimpleName() + " - onCreate()",
                       Toast.LENGTH_SHORT).show();
    }

    // startService()が呼ばれた回数だけ呼び出される
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        // ★ポイント2★ 同一アプリからのIntentであっても、受信Intentの安全性を確認する
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        String param = intent.getStringExtra("PARAM");
        Toast.makeText(this,
                       String.format("パラメータ「%s」を受け取った。", param),
                       Toast.LENGTH_LONG).show();
        
        // サービスは明示的に終了させる
        // stopSelf や stopService を実行したときにサービスを終了する
        // START_NOT_STICKY は、メモリが少ない等でkillされた場合に自動的には復帰しない
        return Service.START_NOT_STICKY;
    }
    
    // Serviceが終了するときに1回だけ呼び出される
    @Override
    public void onDestroy() {
        Toast.makeText(this,
                       this.getClass().getSimpleName() + " - onDestroy()",
                       Toast.LENGTH_SHORT).show();
    }

    @Override
    public IBinder onBind(Intent intent) {
        // このサービスにはバインドしない
        return null;
    }
}

次に非公開Serviceを利用するActivityのサンプルコードを示す。

ポイント(Serviceを利用する):

  1. 同一アプリ内Serviceはクラス指定の明示的Intentで呼び出す
  2. 利用先アプリは同一アプリであるから、センシティブな情報を送信してもよい
  3. 結果を受け取る場合、同一アプリ内Serviceからの結果情報であっても、受信データの安全性を確認する
PrivateUserActivity.java
/*
 * Copyright (C) 2012-2021 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.service.privateservice;

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

public class PrivateUserActivity extends Activity {

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.privateservice_activity);
    }
    
    // サービス開始
    
    public void onStartServiceClick(View v) {
        // ★ポイント4★ 同一アプリ内Serviceはクラス指定の明示的Intentで呼び出す                
        Intent intent = new Intent(this, PrivateStartService.class);
          
        // ★ポイント5★ 利用先アプリは同一アプリであるから、センシティブな情報を送信してもよい
        intent.putExtra("PARAM", "センシティブな情報");

        startService(intent);
    }
    
    // サービス停止ボタン
    public void onStopServiceClick(View v) {
        doStopService();
    }
        
    @Override
    public void onStop(){
        super.onStop();
        // サービスが終了していない場合は終了する
        doStopService();
    }
    // サービスを停止する
    private void doStopService() {
        // ★ポイント4★ 同一アプリ内Serviceはクラス指定の明示的Intentで呼び出す                
        Intent intent = new Intent(this, PrivateStartService.class);
        stopService(intent);            
    }

    // IntentService 開始ボタン

    public void onIntentServiceClick(View v) {
        // ★ポイント4★ 同一アプリ内Serviceはクラス指定の明示的Intentで呼び出す                
        Intent intent = new Intent(this, PrivateIntentService.class);
          
        // ★ポイント5★ 利用先アプリは同一アプリであるから、センシティブな情報を送信してもよい
        intent.putExtra("PARAM", "センシティブな情報");

        startService(intent);
    }
}

4.4.1.2. 公開Serviceを作る・利用する

公開Serviceは、不特定多数のアプリに利用されることを想定したServiceである。マルウェアが送信した情報(Intentなど)を受信することがあることに注意が必要である。また、Service起動のIntentがマルウェアに受信される可能性があるため公開Serviceの起動には明示的Intentを使用し、さらにServiceでは<intent-filter>を宣言しないようにすべきである。

以下、IntentService型のServiceを使用した例を示す。

ポイント(Serviceを作る):

  1. Intent Filterを定義せず、exported="true"を明示的に設定する
  2. 受信Intentの安全性を確認する
  3. 結果を返す場合、センシティブな情報を含めない
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.service.publicservice" >

  <!-- API 28 -->
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

  <application
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name"
      android:allowBackup="false" >
    
    <!-- 最も標準的なService -->
    <!-- ★ポイント1★ Intent Filterを定義せず、exported="true"を明示的に設定する -->
    <service android:name=".PublicStartService" android:exported="true" />

    <!-- IntentServiceを継承したService -->
    <!-- ★ポイント1★ Intent Filterを定義せず、exported="true"を明示的に設定する -->
    <service android:name=".PublicIntentService" android:exported="true" />

  </application>

</manifest>
PublicIntentService.java
/*
 * Copyright (C) 2012-2021 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.service.publicservice;

import android.app.IntentService;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.widget.Toast;

public class PublicIntentService extends IntentService{

    public static final String INTENT_CHANNEL = "intent_channel";

    /**
     * IntentServiceを継承した場合、引数無しのコンストラクタを必ず用意する。
     * これが無い場合、エラーになる。
     */
    public PublicIntentService() {
        super("CreatingTypeBService");
    }

    // Serviceが起動するときに1回だけ呼び出される
    @Override
    public void onCreate() {
        super.onCreate();
        
        Toast.makeText(this,
                       this.getClass().getSimpleName() + " - onCreate()",
                       Toast.LENGTH_SHORT).show();
    }
    
    // Serviceで行いたい処理をこのメソッドに記述する
    @Override
    protected void onHandleIntent(Intent intent) {
        if (Build.VERSION.SDK_INT >= 26) {
            Context context = getApplicationContext();
            String title = context.getString(R.string.app_name);
            NotificationChannel default_channel =
                new NotificationChannel(INTENT_CHANNEL,"Intent Channel",
                                        NotificationManager.IMPORTANCE_DEFAULT);
            NotificationManager notificationManager =
                (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE);
            notificationManager.createNotificationChannel(default_channel);
            Notification notification = new Notification.Builder(context, INTENT_CHANNEL)
                    .setContentTitle(title)
                    .setSmallIcon(android.R.drawable.btn_default)
                    .setContentText("Intent Channel")
                    .setAutoCancel(true)
                    .setWhen(System.currentTimeMillis())
                    .build();
            startForeground(1, notification);
        }
        // ★ポイント2★ 受信Intentの安全性を確認する
        // 公開Activityであるため利用元アプリがマルウェアである可能性がある。
        // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
        String param = intent.getStringExtra("PARAM");
        Toast.makeText(this,
                       String.format("パラメータ「%s」を受け取った。", param),
                       Toast.LENGTH_LONG).show();
    }


    // Serviceが終了するときに1回だけ呼び出される
    @Override
    public void onDestroy() {
        Toast.makeText(this,
                       this.getClass().getSimpleName() + " - onDestroy()",
                       Toast.LENGTH_SHORT).show();
    }
}

次に公開Serviceを利用するActivityのサンプルコードを示す。

ポイント(Serviceを利用する):

  1. Serviceは明示的Intentで呼び出す
  2. センシティブな情報を送信してはならない
  3. 結果を受け取る場合、結果データの安全性を確認する
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.service.publicserviceuser" >

  <queries>
    <package android:name="org.jssec.android.service.publicservice" />
  </queries>
  
  <application
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name"
      android:allowBackup="false" >
    <activity
        android:name=".PublicUserActivity"
        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>
PublicUserActivity.java
/*
 * Copyright (C) 2012-2021 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.service.publicserviceuser;

import android.app.Activity;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.view.View;

public class PublicUserActivity extends Activity {

    // 利用先Service情報
    private static final String TARGET_PACKAGE =
        "org.jssec.android.service.publicservice";
    private static final String TARGET_START_CLASS =
        "org.jssec.android.service.publicservice.PublicStartService";
    private static final String TARGET_INTENT_CLASS =
        "org.jssec.android.service.publicservice.PublicIntentService";

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

        setContentView(R.layout.publicservice_activity);
    }
    
    // サービス開始
    public void onStartServiceClick(View v) {              

        Intent intent =
            new Intent("org.jssec.android.service.publicservice.action.startservice");

        // ★ポイント4★ Serviceは明示的Intentで呼び出す
        intent.setClassName(TARGET_PACKAGE, TARGET_START_CLASS);
          
        // ★ポイント5★ センシティブな情報を送信してはならない
        intent.putExtra("PARAM", "センシティブではない情報");

        if (Build.VERSION.SDK_INT >= 26) {
            startForegroundService(intent);
        } else {
            startService(intent);
        }
        
        // ★ポイント6★ 結果を受け取る場合、結果データの安全性を確認する
        // 本サンプルは startService()を使ったService利用の例の為、結果情報は受け取らない
    }
    
    // サービス停止ボタン
    public void onStopServiceClick(View v) {
        doStopService();
    }
        
    // IntentService 開始ボタン

    public void onIntentServiceClick(View v) {      
        Intent intent =
            new Intent("org.jssec.android.service.publicservice.action.intentservice");

        // ★ポイント4★ Serviceは明示的Intentで呼び出す
        intent.setClassName(TARGET_PACKAGE, TARGET_INTENT_CLASS);

        // ★ポイント5★ センシティブな情報を送信してはならない
        intent.putExtra("PARAM", "センシティブではない情報");

        if (Build.VERSION.SDK_INT >= 26) {
            startForegroundService(intent);
        } else {
            startService(intent);
        }
    }
        
    @Override
    public void onStop(){
        super.onStop();
        // サービスが終了していない場合は終了する
        doStopService();
    }
    
    // サービスを停止する
    private void doStopService() {            
        Intent intent =
            new Intent("org.jssec.android.service.publicservice.action.startservice");

        // ★ポイント4★ Serviceは明示的Intentで呼び出す
        intent.setClassName(TARGET_PACKAGE, TARGET_START_CLASS);

        stopService(intent);            
    }
}

4.4.1.3. パートナー限定Service

パートナー限定Serviceは、特定のアプリだけから利用できるServiceである。パートナー企業のアプリと自社アプリが連携してシステムを構成し、パートナーアプリとの間で扱う情報や機能を守るために利用される。

以下、AIDL bind型のServiceを使用した例を示す。

ポイント(Serviceを作る):

  1. Intent Filterを定義せず、exported="true"を明示的に設定する
  2. 利用元アプリの証明書がホワイトリストに登録されていることを確認する
  3. onBind(onStartCommand,onHandleIntent)で呼び出し元がパートナーかどうか判別できない
  4. パートナーアプリからのIntentであっても、受信Intentの安全性を確認する
  5. パートナーアプリに開示してよい情報に限り返送してよい

なお、ホワイトリストに指定する利用先アプリの証明書ハッシュ値の確認方法は「5.2.1.3. アプリの証明書のハッシュ値を確認する方法」を参照すること。

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.jssec.android.service.partnerservice.aidl" >
  
  <application
      android:icon="@drawable/ic_launcher"
      android:label="@string/app_name"
      android:allowBackup="false">
    
    <!-- AIDLを利用したService -->
    <!-- ★ポイント1★ Intent Filterを定義せず、exported="true"を明示的に設定する -->
    <service 
        android:name="org.jssec.android.service.partnerservice.aidl.PartnerAIDLService" 
        android:exported="true" />
  </application>

</manifest>

今回の例ではAIDLファイルを2つ作成する。1つは、ServiceからActivityにデータを渡すためのコールバックインターフェースで、もう1つはActivityからServiceにデータを渡し、情報を取得するインターフェースである。なお、AIDLファイルに記述するパッケージ名は、javaファイルに記述するパッケージ名と同様に、AIDLファイルを作成するディレクトリ階層に一致させる必要がある。

IPartnerAIDLServiceCallback.aidl
package org.jssec.android.service.partnerservice.aidl;

interface IPartnerAIDLServiceCallback {
    /**
     * 値が変わった時に呼び出される
     */
    void valueChanged(String info);
}
IPartnerAIDLService.aidl
package org.jssec.android.service.partnerservice.aidl;

import org.jssec.android.service.partnerservice.aidl.IExclusiveAIDLServiceCallback;

interface IPartnerAIDLService {

    /**
     * コールバックを登録する
     */
    void registerCallback(IPartnerAIDLServiceCallback cb);
    
    /**
     * 情報を取得する
     */     
    String getInfo(String param);

    /**
     * コールバックを解除する
     */
    void unregisterCallback(IPartnerAIDLServiceCallback cb);
}
PartnerAIDLService.java
/*
 * Copyright (C) 2012-2021 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.service.partnerservice.aidl;

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

import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.os.RemoteCallbackList;
import android.os.RemoteException;
import android.widget.Toast;

public class PartnerAIDLService extends Service {
    private static final int REPORT_MSG = 1;
    private static final int GETINFO_MSG = 2;
    
    // Serviceからクライアントに通知する値
    private int mValue = 0;

    // ★ポイント2★ 利用元アプリの証明書がホワイトリストに登録されていることを確認する
    private static PkgCertWhitelists sWhitelists = null;
    private static void buildWhitelists(Context context) {
        boolean isdebug = Utils.isDebuggable(context);
        sWhitelists = new PkgCertWhitelists();
                
        // パートナーアプリ org.jssec.android.service.partnerservice.aidluser の証明書ハッシュ値を登録
        sWhitelists.add("org.jssec.android.service.partnerservice.aidluser", isdebug ?
            // debug.keystoreの"androiddebugkey"の証明書ハッシュ値
            "0EFB7236 328348A9 89718BAD DF57F544 D5CCB4AE B9DB34BC 1E29DD26 F77C8255" :
            // keystoreの"partner key"の証明書ハッシュ値
            "1F039BB5 7861C27A 3916C778 8E78CE00 690B3974 3EB8259F E2627B8D 4C0EC35A");
                
        // 以下同様に他のパートナーアプリを登録...
    }
        
    private static boolean checkPartner(Context context, String pkgname) {
        if (sWhitelists == null) buildWhitelists(context);
        return sWhitelists.test(context, pkgname);
    }
    
    // コールバックを登録するオブジェクト。
    // RemoteCallbackList の提供するメソッドはスレッドセーフになっている。
    private final RemoteCallbackList<IPartnerAIDLServiceCallback> mCallbacks =
        new RemoteCallbackList<>();

    // コールバックに対してServiceからデータを送信するためのHandler
    protected static class ServiceHandler extends Handler{

        private Context mContext;
        private RemoteCallbackList<IPartnerAIDLServiceCallback> mCallbacks;
        private int mValue = 0;

        public ServiceHandler(Context context,
                              RemoteCallbackList<IPartnerAIDLServiceCallback> callback, int value){
            this.mContext = context;
            this.mCallbacks = callback;
            this.mValue = value;
        }

        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
            case REPORT_MSG: {
                if(mCallbacks == null){
                    return;
                }
                // 通知を開始する
                // beginBroadcast()は、getBroadcastItem()で取得可能なコピーを作成している
                final int N = mCallbacks.beginBroadcast();
                for (int i = 0; i < N; i++) {
                    IPartnerAIDLServiceCallback target = mCallbacks.getBroadcastItem(i);
                    try {
                        // ★ポイント5★ パートナーアプリに開示してよい情報に限り送信してよい
                        target
                        .valueChanged("パートナーアプリに開示してよい情報(callback from Service) No."
                                      + (++mValue));

                    } catch (RemoteException e) {
                        // RemoteCallbackListがコールバックを管理しているので、
                        // ここではunregeisterしない
                        // RemoteCallbackList.kill()によって全て解除される
                    }
                }
                // finishBroadcast()は、beginBroadcast()と対になる処理
                mCallbacks.finishBroadcast();

                // 10秒後に繰り返す
                sendEmptyMessageDelayed(REPORT_MSG, 10000);
                break;
            }
            case GETINFO_MSG: {
                if(mContext != null) {
                    Toast.makeText(mContext,
                                   (String) msg.obj, Toast.LENGTH_LONG).show();
                }
                break;
            }
            default:
                super.handleMessage(msg);
                break;
            } // switch
        }
    }

    protected final ServiceHandler mHandler = new ServiceHandler(this, mCallbacks, mValue);

    // AIDLで定義したインターフェース
    private final IPartnerAIDLService.Stub mBinder = new IPartnerAIDLService.Stub() {
            private boolean checkPartner() {
                Context ctx = PartnerAIDLService.this;
                if (!PartnerAIDLService
                    .checkPartner(ctx, Utils.getPackageNameFromUid(ctx, getCallingUid()))) {
                    mHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            Toast.makeText(PartnerAIDLService.this,
                                          "利用元アプリはパートナーアプリではない。",
                                           Toast.LENGTH_LONG).show();
                        }
                    });
                    return false;
                }
                return true;
            }
            public void registerCallback(IPartnerAIDLServiceCallback cb) {
                // ★ポイント2★ 利用元アプリの証明書がホワイトリストに登録されていることを確認する
                if (!checkPartner()) {
                    return;
                }
                if (cb != null) mCallbacks.register(cb);
            }
            public String getInfo(String param) {

                // ★ポイント2★ 利用元アプリの証明書がホワイトリストに登録されていることを確認する
                if (!checkPartner()) {
                    return null;
                }
                // ★ポイント4★ パートナーアプリからのIntentであっても、受信Intentの安全性を確認する
                // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。
                Message msg = new Message();
                msg.what = GETINFO_MSG;
                msg.obj =
                    String.format("パートナーアプリからのメソッド呼び出し。「%s」を受信した。",
                                  param);
                PartnerAIDLService.this.mHandler.sendMessage(msg);
            
                // ★ポイント5★ パートナーアプリに開示してよい情報に限り返送してよい
                return "パートナーアプリに開示してよい情報(method from Service)";
            }
        
            public void unregisterCallback(IPartnerAIDLServiceCallback cb) {
                // ★ポイント2★ 利用元アプリの証明書がホワイトリストに登録されていることを確認する
                if (!checkPartner()) {
                    return;
                }
 
                if (cb != null) mCallbacks.unregister(cb);
            }
        };
    
    @Override
    public IBinder onBind(Intent intent) {
        // ★ポイント3★ onBind(onStartCommand,onHandleIntent)で呼び出し元がパートナーかどうか判別できない
        // AIDL で定義したメソッドの呼び出し毎にチェックが必要になる。

        return mBinder;
    }
    
    @Override
    public void onCreate() {
        Toast.makeText(this,
                       this.getClass().getSimpleName() + " - onCreate()",
                       Toast.LENGTH_SHORT).show();

        // Serviceが実行中の間は、定期的にインクリメントした数字を通知する
        mHandler.sendEmptyMessage(REPORT_MSG);

    }
    
    @Override
    public void onDestroy() {
        Toast.makeText(this,
                      this.getClass().getSimpleName() + " - onDestroy()",
                       Toast.LENGTH_SHORT).show();
        
        // コールバックを全て解除する
        mCallbacks.kill();
        
        mHandler.removeMessages(REPORT_MSG);
    }

}
PkgCertWhitelists.java
/*
 * Copyright (C) 2012-2021 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-2021 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();
    }
}

次にパートナー限定Serviceを利用するActivityのサンプルコードを示す。

ポイント(Serviceを利用する):

  1. 利用先パートナー限定Serviceアプリの証明書がホワイトリストに登録されていることを確認する
  2. 利用先パートナー限定アプリに開示してよい情報に限り送信してよい
  3. 明示的Intentによりパートナー限定Serviceを呼び出す
  4. パートナー限定アプリからの結果情報であっても、受信Intentの安全性を確認する
PartnerAIDLUserActivity.java
/*
 * Copyright (C) 2012-2021 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.service.partnerservice.aidluser;

import org.jssec.android.service.partnerservice.aidl.IPartnerAIDLService;
import org.jssec.android.service.partnerservice.aidl.IPartnerAIDLServiceCallback;
import org.jssec.android.service.partnerservice.aidl.R;
import org.jssec.android.shared.PkgCertWhitelists;
import org.jssec.android.shared.Utils;

import android.app.Activity;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.os.RemoteException;
import android.view.View;
import android.widget.Toast;

public class PartnerAIDLUserActivity extends Activity {

    private boolean mIsBound;
    private Context mContext;
    
    private final static int MGS_VALUE_CHANGED = 1;
    
    // ★ポイント6★ 利用先パートナー限定Serviceアプリの証明書がホワイトリストに登録されていることを確認する
    private static PkgCertWhitelists sWhitelists = null;
    private static void buildWhitelists(Context context) {
        boolean isdebug = Utils.isDebuggable(context);
        sWhitelists = new PkgCertWhitelists();
                
        // パートナー限定Serviceアプリ org.jssec.android.service.partnerservice.aidl の証明書ハッシュ値を登録
        sWhitelists.add("org.jssec.android.service.partnerservice.aidl", isdebug ?
            // debug.keystoreの"androiddebugkey"の証明書ハッシュ値
            "0EFB7236 328348A9 89718BAD DF57F544 D5CCB4AE B9DB34BC 1E29DD26 F77C8255" :
            // keystoreの"my company key"の証明書ハッシュ値
            "D397D343 A5CBC10F 4EDDEB7C A10062DE 5690984F 1FB9E88B D7B3A7C2 42E142CA");
                
        // 以下同様に他のパートナー限定Serviceアプリを登録...
    }
    private static boolean checkPartner(Context context, String pkgname) {
        if (sWhitelists == null) buildWhitelists(context);
        return sWhitelists.test(context, pkgname);
    }

    // 利用先のパートナー限定Activityに関する情報
    private static final String TARGET_PACKAGE =
        "org.jssec.android.service.partnerservice.aidl";
    private static final String TARGET_CLASS =
        "org.jssec.android.service.partnerservice.aidl.PartnerAIDLService";

    private static class ReceiveHandler extends Handler{
        private Context mContext;

        public ReceiveHandler(Context context){
            this.mContext = context;
        }

        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
            case MGS_VALUE_CHANGED: {
                String info = (String)msg.obj;
                Toast.makeText(mContext, String.format("コールバックで「%s」を受信した。", info),
                               Toast.LENGTH_SHORT).show();
                break;
            }
            default:
                super.handleMessage(msg);
                break;
            } // switch
        }

    }

    private final ReceiveHandler mHandler = new ReceiveHandler(this);

    // AIDLで定義したインターフェース。Serviceからの通知を受け取る。
    private final IPartnerAIDLServiceCallback.Stub mCallback =
        new IPartnerAIDLServiceCallback.Stub() {
            @Override
            public void valueChanged(String info) throws RemoteException {
                Message msg = mHandler.obtainMessage(MGS_VALUE_CHANGED, info);
                mHandler.sendMessage(msg);
            }
        };
    
    // AIDLで定義したインターフェース。Serviceへ通知する。
    private IPartnerAIDLService mService = null;
    
    // Serviceと接続する時に利用するコネクション。bindServiceで実装する場合は必要になる。
    private ServiceConnection mConnection = new ServiceConnection() {

        // Serviceに接続された場合に呼ばれる
        @Override
        public void onServiceConnected(ComponentName className, IBinder service) {
            mService = IPartnerAIDLService.Stub.asInterface(service);
            
            try{
                // Serviceに接続
                mService.registerCallback(mCallback);
                
            }catch(RemoteException e){
                // Serviceが異常終了した場合
            }
            
            Toast.makeText(mContext,
                          "Connected to service", Toast.LENGTH_SHORT).show();
        }

        // Serviceが異常終了して、コネクションが切断された場合に呼ばれる
        @Override
        public void onServiceDisconnected(ComponentName className) {
            Toast.makeText(mContext,
                          "Disconnected from service", Toast.LENGTH_SHORT).show();
        }
    };
    
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        setContentView(R.layout.partnerservice_activity);

        mContext = this;
    }

    // サービス開始ボタン
    public void onStartServiceClick(View v) {
        // bindServiceを実行する
        doBindService();
    }
        
    // 情報取得ボタン
    public void onGetInfoClick(View v) {
        getServiceinfo();
    }
        
    // サービス停止ボタン
    public void onStopServiceClick(View v) {
        doUnbindService();
    }
        
    @Override
    public void onDestroy() {
        super.onDestroy();
        doUnbindService();
    }

    /**
     * Serviceに接続する
     */
    private void doBindService() {
        if (!mIsBound){
            // ★ポイント6★ 利用先パートナー限定Serviceアプリの証明書がホワイトリストに登録されていることを確認する
            if (!checkPartner(this, TARGET_PACKAGE)) {
                Toast.makeText(this,
                              "利用先 Service アプリはホワイトリストに登録されていない。",
                               Toast.LENGTH_LONG).show();
                return;
            }
            
            Intent intent = new Intent();
                
            // ★ポイント7★ 利用先パートナー限定アプリに開示してよい情報に限り送信してよい
            intent.putExtra("PARAM", "パートナーアプリに開示してよい情報");
                
            // ★ポイント8★ 明示的Intentによりパートナー限定Serviceを呼び出す
            intent.setClassName(TARGET_PACKAGE, TARGET_CLASS);
                 
            bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
            mIsBound = true;
        }
    }

    /**
     * Serviceへの接続を切断する
     */
    private void doUnbindService() {
        if (mIsBound) {
            // 登録していたレジスタがある場合は解除
            if(mService != null){
                try{
                    mService.unregisterCallback(mCallback);
                }catch(RemoteException e){
                    // Serviceが異常終了していた場合
                    // サンプルにつき処理は割愛
                }
            }
            
            unbindService(mConnection);
            
            Intent intent = new Intent();
        
            // ★ポイント8★ 明示的Intentによりパートナー限定Serviceを呼び出す
            intent.setClassName(TARGET_PACKAGE, TARGET_CLASS);
 
            stopService(intent);
          
            mIsBound = false;
        }
    }

    /**
     * Serviceから情報を取得する
     */
    void getServiceinfo() {
        if (mIsBound && mService != null) {
            String info = null;
            
            try {
                // ★ポイント7★ 利用先パートナー限定アプリに開示してよい情報に限り送信してよい
                info =
                    mService.getInfo("パートナーアプリに開示してよい情報(method from activity)");
            } catch (RemoteException e) {
                e.printStackTrace();
            }
            // ★ポイント9★ パートナー限定アプリからの結果情報であっても、受信Intentの安全性を確認する
            // サンプルにつき割愛。「3.2 入力データの安全性を確認する」を参照。            
            Toast.makeText(mContext,
                           String.format("サービスから「%s」を取得した。", info),
                           Toast.LENGTH_SHORT).show();
        }
    }
}
PkgCertWhitelists.java
/*
 * Copyright (C) 2012-2021 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/L