ADB (Android Debug Bridge) のプロトコル 後編

概要

ADB の Java クライアントを試作してみようと取り組んでみたら深みにはまっていったお話。 とても長いので前後編に分けました。

雑談

nosix.hatenablog.com

幸せになれたと思ったのに

コマンドを実行できるようになった。 しかし、再度接続を試みる際に署名を送信しても認証に失敗する。 署名による認証に失敗すると公開鍵を再送信することになり、ユーザーに確認を求めるダイアログが表示される。 これは煩わしい。 ダイアログ表示はなくしたい。 署名で認証せねば。 ここから調べ物の旅が始まる。

署名を生成する処理を JavaAPI を使用して Kotlin でコードを書くと次のようになる。 token はサーバーから送信されたトークン。 NONE はダイジェストを使用しない指定。 (標準アルゴリズム名のドキュメント)

fun sign(token: ByteArray, key: PrivateKey): ByteArray {
    Signature.getInstance("NONEwithRSA").run {
        initSign(key)
        update(token)
        return sign()
    }
}

実行しても認証に成功しない。

署名の仕組み

ADB の署名はどのように行われているのか。

github.com

adb_auth_host.cpp に adb_auth_sign 関数がある。 下記を行うだけの簡単なお仕事。

RSA_sign(NID_sha1, token, token_size, sig, &len, key)

これは OpenSSL の RSA_sign 関数。 OpenSSL の RSA_sign 関数を調べてみる。 ソースコードを読むが知識なしには何を行っているのかわからない。 RSA_sign manpage も読んでみる。 PKCS #1 v2.0 というキーワードを得た。

RFC 2437 - PKCS #1: RSA Cryptography Specifications Version 2.0

知識不足の状態で英語を読むのは辛いと思っていたら、日本語で読み解いてくれている記事を発見。

qiita.com

記事を読み進めていくと、他記事へのリンク発見。 これこそが求めていた情報。 OpenSSL の RSA_sign 関数の中身はこれではなかろうか。

自堕落な技術者の日記 : 図説RSA署名の巻 - livedoor Blog(ブログ)

記事中の DigestInfo と RFC2437 の内容を較べてみる。 ダイジェストアルゴリズムは様々あるが RSA_sign 関数に NID_sha1 を渡していることから SHA-1 (Secure Hash Algorithm) と推測される。 RFC2437 中には他にも MD2, MD5 (Message Digest Algorithm) の記述がある。 RFC2437 から関係のありそうな部分のみを抜粋する。

DigestInfo ::= SEQUENCE {
     digestAlgorithm  AlgorithmIdentifier,
     digest OCTET STRING }

sha1Identifier ::= AlgorithmIdentifier { id-sha1, NULL }

id-sha1 OBJECT IDENTIFIER ::=
     { iso(1) identified-organization(3) oiw(14) secsig(3)
       algorithms(2) 26 }

上記の表記法は標準化されており ASN.1 (Abstract Syntax Notation One) と呼ばれている。 ASN.1 はデータ構造(型)を定義しており、実際のデータをこの型にあてはめていく。 データを当てはめるにあたっての規則として BER (Basic Encoding Rules) がある。

ASN.1 バイナリ変換規則 (BER, CER, DER)

自堕落な技術者の日記 : 図説RSA署名の巻 で図示されている DigestInfo の BER によるデータ表現は下記のとおり。

30:21:30:09:06:05:2b:0e:03:02:1a:05:00:04:14

バイトを読み解く。

  1. 0x30 : SEQUENCE のタグ番号は 0x10、構造型なので構造化フラグが 1、よって 0x30
  2. 0x21 : 33 bytes (13 bytes + トークンの 20 bytes)
  3. 0x30 : SEQUENCE
  4. 0x09 : 9 bytes
  5. 0x06 : OBJECT IDENTIFIER
  6. 0x05 : RFC2437 では iso(1) だが?
  7. 0x2b : RFC2437 では identified-organization(3) だが?
  8. 0x0e : oiw(14)
  9. 0x03 : secsig(3)
  10. 0x02 : algorighms(2)
  11. 0x1a : 26
  12. 0x05 : NULL
  13. 0x00 : 終端
  14. 0x04 : OCTET STRING
  15. 0x14 : 20 bytes
  16. 以降、トークンのデータ (20 bytes)

なお、サーバーから受信したトークンは 20 bytes のデータで下記のような内容であった。 SHA-1 は 20 bytes の値を生成するため、トークンは SHA-1ハッシュ値であると確信を深める。

07:ee:71:40:54:fd:98:00:db:38:b5:c4:45:a8:c8:c5:7f:d6:ba:82

署名の内容を確認する

Signature.getInstance において NONEwithRSA を指定して署名を生成したとき、認証に失敗していた。 実際に生成されている署名を確認するために、実験用のコードを書く。 ECB については、理解してるつもりの SSL/TLS でも、もっと理解したら面白かった話 · けんごのお屋敷 の暗号モードが詳しい。

fun toByteArray(buffer: String): ByteArray =
        buffer.split(":").map { Integer.parseInt(it, 16).toByte() }.toByteArray()

fun ByteArray.toHexString(): String =
        joinToString(":") { String.format("%02x", it) }

fun testSign() {
    val keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair()
    val token = toByteArray("07:ee:71:40:54:fd:98:00:db:38:b5:c4:45:a8:c8:c5:7f:d6:ba:82")

    val signature = Signature.getInstance("NONEwithRSA").run {
        initSign(keyPair.private)
        update(token)
        sign()
    }
    val decryptedSignature = Cipher.getInstance("RSA/ECB/NoPadding").run {
        init(Cipher.DECRYPT_MODE, keyPair.public)
        doFinal(signature)
    }
    println("size: ${decryptedSignature.size}")
    println("data: ${decryptedSignature.toHexString()}")
}

実行結果。data は長いので 16 bytes ごとで改行している。

size: 256
data:
00:01:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:00:07:ee:71:40:
54:fd:98:00:db:38:b5:c4:45:a8:c8:c5:7f:d6:ba:82

NONEwithRSA では、DigestInfo がなく、トークンのみ。

次に、NONEwithRSA を SHA1withRSA に変更して再度実行。

size: 256
data:
00:01:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:00:30:21:30:
09:06:05:2b:0e:03:02:1a:05:00:04:14:98:d2:0c:51:
8a:59:30:f8:64:07:5a:55:fd:68:a2:5f:d4:40:8e:b7

SHA1withRSA では、DigestInfo があり、トークンは SHA-1ハッシュ値になっている。

Signature では期待する結果を得られないので、Cipher を使用して署名を生成する。

fun testSign() {
    val keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair()
    val token = toByteArray("07:ee:71:40:54:fd:98:00:db:38:b5:c4:45:a8:c8:c5:7f:d6:ba:82")

    val signature = Cipher.getInstance("RSA/ECB/PKCS1Padding").run {
        init(Cipher.ENCRYPT_MODE, keyPair.private);
        doFinal(token)
    }
    val decryptedSignature = Cipher.getInstance("RSA/ECB/NoPadding").run {
        init(Cipher.DECRYPT_MODE, keyPair.public)
        doFinal(signature)
    }
    println("size: ${decryptedSignature.size}")
    println("data: ${decryptedSignature.toHexString()}")
}

実行結果。

decr size: 256
decr data:
00:01:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:00:07:ee:71:40:
54:fd:98:00:db:38:b5:c4:45:a8:c8:c5:7f:d6:ba:82

RSA/ECB/PKCS1Padding では、DigestInfo がない。

DIGEST_INFO を用意して追加。

    val DIGEST_INFO = arrayOf(
            0x30,0x21,0x30,0x09,0x06,0x05,0x2b,0x0e,0x03,0x02,0x1a,0x05,0x00,
            0x04,0x14).map { it.toByte() }.toByteArray()

    val signature = Cipher.getInstance("RSA/ECB/PKCS1Padding").run {
        init(Cipher.ENCRYPT_MODE, keyPair.private);
        update(DIGEST_INFO) // 追加
        doFinal(token)
    }

実行結果。

decr size: 256
decr data:
00:01:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:00:30:21:30:
09:06:05:2b:0e:03:02:1a:05:00:04:14:07:ee:71:40:
54:fd:98:00:db:38:b5:c4:45:a8:c8:c5:7f:d6:ba:82

望む結果が得られた。 得られた署名をサーバーに送信したところ、認証を通過できた。

公開鍵の形式

署名の問題は解決できたが、生成した公開鍵を送付すると署名の認証で失敗する。 配布されいる ADB コマンドが生成した公開鍵を送付して、秘密鍵で署名を行うと認証に成功する。 (macOS の環境では ~/.android ディレクトリの adbkey (秘密鍵), adbkey.pub (公開鍵)。) 送付している公開鍵に問題があるようだ。

再び、ソースコードを読んでみる。

platform_system_core/adb_auth_host.cpp at master · android/platform_system_core · GitHub

android_pubkey_encode 関数を呼んでいる。

platform_system_core/android_pubkey.c at master · android/platform_system_core · GitHub

公開鍵は下記の形式にエンコードしたうえで BASE64 エンコードされている。 ANDROID_PUBKEY_MODULUS_SIZE は (2048 / 8)。 鍵サイズは 2048 bits のようだ。

typedef struct RSAPublicKey {
    // Modulus length. This must be ANDROID_PUBKEY_MODULUS_SIZE.
    uint32_t modulus_size_words;

    // Precomputed montgomery parameter: -1 / n[0] mod 2^32
    uint32_t n0inv;

    // RSA modulus as a little-endian array.
    uint8_t modulus[ANDROID_PUBKEY_MODULUS_SIZE];

    // Montgomery parameter R^2 as a little-endian array of little-endian words.
    uint8_t rr[ANDROID_PUBKEY_MODULUS_SIZE];

    // RSA modulus: 3 or 65537
    uint32_t exponent;
}

android_pubkey_encode 関数を参考にして、Kotlin で書いてみる。

    fun adbEncode(key: PublicKey): ByteArray {
        if (key !is RSAPublicKey) throw IllegalArgumentException("PublicKey is not RSAPublicKey.")

        val modulesSize = 2048 / 8
        val moduleSizeWords = modulesSize / 4

        // 2^32
        val r32 = BigInteger.ZERO.setBit(32)

        // -1 / N[0] mod 2^32
        val n0inv = r32.subtract(
                key.modulus.remainder(r32).modInverse(r32)).toInt()

        val modules = key.modulus
                .toByteArray().reversedArray()

        // (2^(rsa_size)) ^ 2 mod N
        val rr = BigInteger.ZERO.setBit(modulesSize * 8)
                .modPow(BigInteger.valueOf(2), key.modulus)
                .toByteArray().reversedArray()

        val exponent = key.publicExponent.toInt()

        val encodedPubKey = ByteArray(4 * 3 + modulesSize * 2).apply {
            ByteBuffer.wrap(this).run {
                order(ByteOrder.LITTLE_ENDIAN)
                putInt(moduleSizeWords)
                putInt(n0inv)
                if (modules.size < modulesSize) {
                    put(modules)
                    put(ByteArray(modulesSize - modules.size)) // 桁が不足する場合は 0 で埋める
                } else {
                    put(modules, 0, modulesSize) // 符号の桁は除く
                }
                if (rr.size < modulesSize) {
                    put(rr)
                    put(ByteArray(modulesSize - rr.size)) // 桁が不足する場合は 0 で埋める
                } else {
                    put(rr, 0, modulesSize) // 符号の桁は除く
                }
                putInt(exponent)
            }
        }

        return encodedPubKey
    }

adbEncode 関数の戻り値を BASE64 エンコードしてサーバーに送信することで、署名による認証に成功するようになった。

以上で、ユーザーにダイアログを表示することなく、自スマホに ADB 接続できるようになった。

まとめ

  • ADB は USB だけでなく、TCP でも接続できる
  • アプリからコマンドを実行するよりも ADB でコマンドを実行する方が多くの権限を与えられる
  • TCP 接続を使えば、アプリから ADB の権限でコマンドを実行できる
  • 但し、USB 接続で adb tcpip コマンドを実行しておく必要がある
  • ADB Protocol Documentation に従えば、実装できる
    • 但し、CRC32 は使用しておらず、単純なチェックサム
    • データが文字列の場合には最後のバイトを 0 にする
    • 送信する署名は、パディング、DigestInfo、トークンをまとめて秘密鍵で暗号化 (RSA/ECB/PKCS1Padding を指定して、DigestInfo の ERB エンコードしたデータを加える)
    • 送信する公開鍵は、struct RSAPublicKey のバイト列を BASE64 エンコードしたデータ (Little Endian)

たくさんの有益な情報を提供してくださっている、各ブログとプロジェクトに感謝!