Javascriptで APKの証明書フィンガープリントを出力する(V3署名対応版)


前回の記事で、ブラウザからAPKのフィンガープリントを出力することができました。
これは、V1署名もしくはJAR署名とよばれる仕組みで署名された情報を出力する仕組みでした。

しかしながら、新しい署名スキームが導入され、新しいバージョンのAndroidにのみ対応しているAPKについては、V1署名がされていない可能性があり、フィンガープリントを抽出できなくなってしまいました。

そのため、新しいV3署名に対応するように、既存のツールを修正しました。
APK署名ブロックの構造さえ理解できれば、その後の処理はJAR署名より若干簡単にできました。

V2以降の署名は、Java付属のkeytoolを使えず、Android Studio付属のapksignerを使わないと確認することができないのですが、このツールを使うことで、開発環境がインストールされていないPCからも簡単に署名を確認できるようになりました。

前回同様、デモページに実装済みですので、よかったら動かしてみてください。
V3署名がある場合にはV3署名、見付からない場合はV1(JAR)署名を抽出する仕組みになっています。

デモページ

新たなAPK署名の構造

V3署名の構造は、V2署名の構造と似ており、その詳細はGoogleのページに説明されています。

APK署名ブロックを探す

上記の通り、ZIPファイルの真ん中に、APK Signing Blockというブロックが挿入されます。
APK Signing Blockの末尾には、 APK Sig Block 42 という文字列 (magic) があり、それを検索することで署名ブロックの場所を見つけることができます。

冒頭のContents of ZIP entries は圧縮されたファイルが入っており非常に長いため、lastIndexOfで後ろから検索します。
最近やっとPromiseの使い方がわかってきた気がする。

            var binstr = await getBinaryString(file);
            var sigMagicInd = binstr.lastIndexOf("APK Sig Block 42"); // MAGIC WORD

        // Binary String を Promiseで返す
        async function getBinaryString(file) {
            return new Promise((resolve, reject) => {
                var apkStrReader = new FileReader();
                apkStrReader.onload = () => {
                    resolve(apkStrReader.result)
                }
                apkStrReader.readAsBinaryString(file);
            });
        }

文字列の直前の 8 byteがAPK署名ブロックのサイズを示しています。つまり、magic文字列からさらに、ブロックのサイズをさかのぼると、ブロックの冒頭にたどり着きます。
この後も出てきますがuint64, uint32も数字はすべてLittle Endianです。

     var buffer = await file.arrayBuffer();
            var apkArr = new Uint8Array(buffer);

            // MAGICの直前にブロックサイズが記載
            // Blockサイズがuint64で入っている
            var bsArr = apkArr.slice(sigMagicInd - 8, sigMagicInd)
            console.log("bsArr: " + bsArr)
            // javascriptでuint64は扱えないのでuint32で取り出す
            var bs = bsArr[0] + bsArr[1] * 256 + bsArr[2] * 256 * 256 + bsArr[3] * 256 * 256 * 256
            console.log("bs: " + bs)

V3署名ブロックを取り出す

APK署名ブロックの冒頭から、V3署名ブロックを表すタグ 0xf05368c0 の場所を探します。
これもリトルエンディアンなので[0xC0, 0x68, 0x53, 0xF0]と逆の配列になります。
ArrayBufferでは配列を検索できないので、magic文字列同様、BinaryStringから検索します。

           // V3署名のタグを探す 0xf05368c0.
            var v3ID = String.fromCharCode(0xc0) + String.fromCharCode(0x68) + String.fromCharCode(0x53) + String.fromCharCode(0xf0);
            var v3SigIDind = binstr.indexOf(v3ID, sigMagicInd - bs);
            console.log("v3SigIDind: " + v3SigIDind)

タグの直前には、APK署名ブロック同様に、ブロックサイズが入っているので、それを取り出して、V3署名ブロックを取り出します。

            // V3 Blockサイズがuint64で入っている
            var v3bsArr = apkArr.slice(v3SigIDind - 8, v3SigIDind)
            console.log("v3bsArr: " + v3bsArr)
            // javascriptでuint64は扱えないのでuint32で取り出す
            var v3bs = v3bsArr[0] + v3bsArr[1] * 256 + v3bsArr[2] * 256 * 256 + v3bsArr[3] * 256 * 256 * 256
            console.log("v3bs: " + v3bs)

            // V3署名ブロックを取り出す
            var v3sigBlock = apkArr.slice(v3SigIDind + 4, sigMagicInd + v3bs)
            console.log("v3sigBlock: " + v3sigBlock)

証明書を取り出す

前回 の通り、証明書フィンガープリントとは、証明書をDER形式にしたデータのハッシュ値です。
GoogleのV3署名の構造のページを見ると、証明書はDER形式で格納されているようなので、そのまま使えそうです。

説明ページの、「長さプレフィックス付き」とは、先頭4バイトがデータの長さになっているということのようです。
最初にダイジェストが記録されているので、ダイジェストの長さを取り出してその分スキップすると、証明書データにたどり着きます。
証明書データを取り出して、そのままSHA-256すると、aprsignerで取得した値と一致し、フィンガープリント抽出が成功しました。

            // certificateを取り出す
            var certInd = 16 + v3sigBlock[12] + v3sigBlock[12 + 1] * 256
            console.log("certInd: " + certInd)
            // console.log("certInd: " + v3sigBlock.slice(certInd , certInd + 8))

            var certLen = v3sigBlock[certInd] + v3sigBlock[certInd + 1] * 256
            console.log("certLen: " + certLen)

            var certBlock = v3sigBlock.slice(certInd + 8, certInd + 4 + certLen)
            console.log("certBlock: " + certBlock)

            // certificate のSHA-256ハッシュを取り出す
            var hashBuffer = await crypto.subtle.digest('SHA-256', certBlock);
            const hashArray = Array.from(new Uint8Array(hashBuffer));                     // convert buffer to byte array
            const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(':'); // convert bytes to hex string

本当は署名は複数存在する場合があるので、処理を繰り返さないといけないんですが、疲れてしまったのでここでおしまいです。

また、V2署名もほとんど同じ方法で取り出せると思います。

そして、さらに、V4署名なるものもすでに存在しているので、いつかまた対応しないといけませんね。