APK ファイルの署名の仕様

APK ファイル (や一部の jar ファイル) において利用される「署名」の内部仕様について解説し,およびアルゴリズム的に OpenSSL 等を利用して自力で署名をおこなってみる。誰得企画であるが,せっかく調査した (そして署名つき JAR ファイルの仕様のひどさに吃驚した) のでメモとして残しておく。
はじめにざっくりいうと,APK ファイルの署名の仕様は Java における JAR ファイルの署名 (のサブセット) に独自仕様を追加したものである。
build/tools/signapk/SignApk.java を読むのが一番わかりやすい。
以下のサンプルでは build/target/product/security/ に存在する testkey.pk8testkey.x509.pem*1 を署名用鍵・証明書として利用する。

testkey.pk8
RSA 秘密鍵 (の PKCS #8 形式)
testkey.x509.pem
対応する X.509 公開鍵証明書

サンプルなのでこのようなテスト用キーを利用したが,本来であれば自分用の鍵・証明書を development/tools/make_keykeytool を利用して作成するべきである。
まずはじめに,秘密鍵PKCS #8 形式のままでは,OpenSSL でのとりまわしが不便なので,openssl pkcs8 コマンドで一般的な PEM 形式に変換しておく。

$ openssl pkcs8 -in testkey.pk8 -inform DER -nocrypt -out testkey.pem

APK ファイルなどを解凍すればわかるが,アーカイブ内の

  • META-INF/MANIFEST.MF
  • META-INF/CERT.SF
  • META-INF/CERT.RSA

これらのファイルが署名にまつわるファイルである。

普通に署名する方法

あまり本文書の守備範囲ではないが,一応書いておく。
コマンドラインから APK ファイル等を署名するには,AOSP の build/tools/signapk/ 以下をビルドして生成されるコマンド signapk.jar を用いる。

$ java -jar signapk.jar
Usage: signapk [-w] publickey.x509[.pem] privatekey.pk8 input.jar output.jar

-wオプションを付与すると後述するアーカイブ全体の署名も埋め込むことになる。
もちろん Java に付属する jarsigner を利用してもよい*2。ただ,jarsigner ではアーカイブ全体の署名はおこなうことはできない。

META-INF/MANIFEST.MF の構造と生成

ファイルを覗いてみると以下のような構造になっている。

Manifest-Version: 1.0
Created-By: 1.0 (Android)

Name: res/layout/main.xml
SHA1-Digest: Xal5w1XkBBgw1JtbLohBa8RxDDk=

Name: res/drawable-ldpi/icon.png
SHA1-Digest: i7vxaosoiS+9HzKB7ZgIsXMYRLY=

Name: AndroidManifest.xml
SHA1-Digest: 8Op8I2+2AKZZS9CpAzTnwi7zidU=

...... (後略)

各ファイルの SHA1 digest (の BASE64 形式) を格納しているだけである。

後述するスクリプトで生成した例。

$ perl make-manifest.pl hello > MANIFEST.MF

$ cat MANIFEST.MF
Signature-Version: 1.0
Created-By: 1.0 (Android SignApk)

Name: AndroidManifest.xml
SHA1-Digest: 8Op8I2+2AKZZS9CpAzTnwi7zidU=

Name: resources.arsc
SHA1-Digest: iAvBaNTIddA6lRk1v+SAf8IKH+M=

......

おおもとは各エントリは Java の Map のキー列挙順になってしまうので,この Perl スクリプトによる出力 (辞書順) とは順序が異なってしまう。あくまでコンセプトコードとしてみること。

META-INF/CERT.SF の構造と生成

はっきりいって (署名という意味では) 存在意義のまるでないファイル。
2011-08-19 追記: JAR ファイルの仕様としては複数人が別々に署名することを想定して .SF ファイル (signature file) を用意したようです。また,必ず CERT.SF / .RSA という名前である必要はありません*3。もともと別々の JAR ファイルを一つに結合した場合などには便利かもしれません (が,Android プログラミングにおいて登場することはないでしょう)。
META-INF/MANIFEST.MF さえあれば再生成できる (RSA 暗号鍵などは必要ない)。

Signature-Version: 1.0
Created-By: 1.0 (Android)
SHA1-Digest-Manifest: sGz/74W/RDIlQzFdV1n7XqlvR3Y=

Name: res/layout/main.xml
SHA1-Digest: aQj8hpWCMdsFwVuVLtqolo9seCQ=

Name: AndroidManifest.xml
SHA1-Digest: h+gwFCRgDwq0hbqvYNt1UyLYiHg=

...... (後略)

アルゴリズムがいささかややこしいが,

  1. まず SHA1 digest の演算器を初期化する
  2. META-INF/MANIFEST.MFSHA1 digest を計算し,SHA1-Digest-Manifest: 属性として出力する
  3. 『digest 演算器のステートはそのまま』
  4. MANIFEST.MF のエントリの SHA1 digest を計算して SHA1-Digest: 属性として出力していく

末尾に付した make-cert-sf.pl のコード (あるいは SignApk.java) を読むほうがわかりやすと思う。

$ perl make-cert-sf.pl MANIFEST.MF > CERT.SF

$ cat CERT.SF
Created-By: 1.0 (Android SignApk)
SHA1-Digest-Manifest: mJEA5BvICy4RRe2EzFxbYp/ndT0=

Name: AndroidManifest.xml
SHA1-Digest: h+gwFCRgDwq0hbqvYNt1UyLYiHg=

Name: resources.arsc
SHA1-Digest: z0OgdzNF/68Zor8TmNbhE97X2S0=

......

META-INF/CERT.RSA の構造と生成

上述の META-INF/CERT.SFRSA 暗号鍵と X.509 公開鍵証明書で署名して PKCS #7 形式で表現したものが META-INF/CERT.RSA である。
openssl pkcs7 コマンドには署名する機能はないので openssl smime コマンドをなぜか使う。

$ openssl smime -sign -inkey testkey.pem -signer testkey.x509.pem -in CERT.SF -outform DER -noattr > CERT.RSA
既存の META-INF/CERT.RSA の解析

ちなみに openssl pkcs7 コマンドを使うと署名 META-INF/CERT.RSA に用いられた (署名者の公開鍵) 証明書を抜き出すことができる (署名に必要な秘密鍵は当然抜き出すことはできない)。
てっとりばやく誰が署名したのかを見るには -noout -print_certs オプションをつけて実行する。

$ openssl pkcs7 -inform DER -in META-INF/CERT.RSA -noout -print_certs

subject=/C=US/ST=California/L=Mountain View/O=Android/OU=Android/CN=Android/emailAddress=android@android.com
issuer=/C=US/ST=California/L=Mountain View/O=Android/OU=Android/CN=Android/emailAddress=android@android.com

より詳細に証明書をみたい場合は -text オプションをつける。

$ openssl pkcs7 -inform DER -in META-INF/CERT.RSA -noout -print_certs -text

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            93:6e:ac:be:07:f2:01:df
        Signature Algorithm: sha1WithRSAEncryption
        Issuer: C=US, ST=California, L=Mountain View, O=Android, OU=Android, CN=Android/emailAddress=android@android.com
        Validity
            Not Before: Feb 29 01:33:46 2008 GMT
            Not After : Jul 17 01:33:46 2035 GMT
        Subject: C=US, ST=California, L=Mountain View, O=Android, OU=Android, CN=Android/emailAddress=android@android.com
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
            RSA Public Key: (2048 bit)
                Modulus (2048 bit):
...... (後略)

証明書自体を PEM 形式で抜き出すには -print_certs オプションだけもちいる。

$ openssl pkcs7 -inform DER -in META-INF/CERT.RSA -print_certs      

subject=/C=US/ST=California/L=Mountain View/O=Android/OU=Android/CN=Android/emailAddress=android@android.com
issuer=/C=US/ST=California/L=Mountain View/O=Android/OU=Android/CN=Android/emailAddress=android@android.com
-----BEGIN CERTIFICATE-----
MIIEqDCCA5CgAwIBAgIJAJNurL4H8gHfMA0GCSqGSIb3DQEBBQUAMIGUMQswCQYD

...... (後略)

この (BEGIN CERTIFICATE 以降の) 証明書は署名に利用した testkey.x509.pem と同一である (あたりまえ)。

アーカイブ全体の署名

ここまでに述べた仕様は Java 署名付き JAR ファイルのサブセット*4仕様であるが,Android APK の署名独自仕様がある。それがアーカイブ全体の署名である。
ただしオプショナルであり,実際にはアプリケーションパッケージ (APK) 等では利用されていない。現状では OTA アップデータアーカイブupdate.zip においてのみ利用されている*5
アーカイブ全体の署名は,簡単にいうと (署名自体を除く) アーカイブ全体を RSA 暗号鍵と X.509 公開鍵証明書で署名して PKCS #7 形式で ZIP ファイルのコメント欄に格納している。
ZIP のコメント欄の仕様は,EOCD レコード以降にそのまま格納されているだけである*6が,アーカイブの署名は具体的には下記のように格納されている。

...... (前略)

{central directory file header}

...... (中略)

{EOCD record} (end of central directory)

"signed by SignApk" [00] (comment; not checked)
{signature in PKCS#7 format}
[XX] [XX] (signature offset from file end in 2 bytes)
[FF] [FF]
[XX] [XX] (comment block size in 2 bytes)

なぜこのようなやや複雑な構造になっているかというと,署名検査ルーチンがアーカイブの末尾のみ読み込んで簡便に署名部分にアクセスできるようにである (後述する cut-wfsig.pl を参照のこと)。

この全体署名の算出のためには,まずは対象となる update.zip を2バイト*7切り詰めて,それへの RSA 署名を算出する。

$ ls -l update.zip
-rw-r--r-- 1 urandroid 159336 2011-08-04 15:49 update.zip

$ cp update.zip body.zip

$ truncate -s 159334 body.zip   # 2バイト少なくする

$ ls -l body.zip
-rw-r--r-- 1 urandroid 159334 2011-08-04 16:37 body.zip

$ openssl smime -sign -inkey testkey.pem -signer testkey.x509.pem -in body.zip -outform DER -noattr -binary > whole.sig

あとは body.zipwhole.sig のサイズを2バイトで埋め込み,先ほどのフォーマットに従って署名を格納すれば,アーカイブ全体署名が完成する。

スクリプト

make-manifest.pl

ファイルツリーから META-INF/MANIFEST.MF を生成するスクリプト

#!perl

use strict;
use warnings;
use File::Find;
use File::Spec::Functions qw( splitpath splitdir );
use Digest::SHA;
use MIME::Base64;

my $CRLF = "\r\n";

print "Signature-Version: 1.0", $CRLF;
print "Created-By: 1.0 (Android SignApk)", $CRLF, $CRLF;

my $target_dir = shift;

find(\&each_node, $target_dir);

exit;

sub each_node {
    my $file = $_;

    return if ! -f $file;

    my ($vol, $d, $f) = splitpath $File::Find::name;
    my @dirs = grep { $_ ne '' } splitdir $d;
    my $name = join '/', @dirs, $f;

    $name =~ s{\A \Q$target_dir\E /? }{}xms;

    return if $name eq 'META-INF/MANIFEST.MF';
    return if $name eq 'META-INF/CERT.SF';
    return if $name eq 'META-INF/CERT.RSA';

    printf "Name: %s%s", $name, $CRLF;
    printf "SHA1-Digest: %s%s", sha1_for_file($file), $CRLF;
    print $CRLF;
}

sub sha1_for_file {
    my $file = shift;

    my $sha = Digest::SHA->new("SHA1");
    $sha->addfile($file);
    return encode_base64($sha->digest(), "");
}
make-cert-sf.pl

META-INF/MANIFEST.MF から META-INF/CERT.SF を生成するスクリプト

#!perl

use strict;
use warnings;
use Digest::SHA;

my $manifest = shift;

my $CRLF = "\r\n";

print "Signature-Version: 1.0", $CRLF;
print "Created-By: 1.0 (Android SignApk)", $CRLF;

my $md = Digest::SHA->new('SHA1');

$md->addfile($manifest);

print "SHA1-Digest-Manifest: ", base64($md->digest), $CRLF, $CRLF;

open my $file, '<', $manifest or die $!;

while (my $line = <$file>) {
    chomp $line;
    last if $line =~ m{\A \s* \z}xmso;
}

my $name = "";
while (my $line = <$file>) {
    $line =~ s{ [\r\n]+ $}{}gxmso;      # custom chomp

    if ($line eq "") {
        $md->add($CRLF);

        print "Name: $name", $CRLF;
        print "SHA1-Digest: ", base64($md->digest), $CRLF, $CRLF;
        next;
    }

    die $line if $line !~ m{^ (\S+) \s* : \s* (.*?) \s* $}xmso;
    my ($key, $data) = ($1, $2);

    if (lc $key eq 'name') {
        $name = $data;
    }

    $md->add("$key: $data" . $CRLF);
}

close $file;

exit;

use MIME::Base64;

sub base64 {
    return encode_base64($_[0], "");
}
cut-wfsig.pl

アーカイブ全体への署名が埋め込まれた update.zip 等から署名単体を切り出すスクリプトPKCS #7 形式なので openssl pkcs7 コマンドでいろいろみることができる。

#!perl

use strict;
use warnings;
use autodie;

my $buf;
my $filename = shift;

open my $file, '<', $filename;

seek $file, -6, 2;

read $file, $buf, 2;

my $offset_sig = unpack 'v', $buf;
printf {*STDERR} "signature offset = %d\n", $offset_sig;

seek $file, -$offset_sig, 2;
read $file, $buf, $offset_sig - 6;

close $file;

print $buf;

*1:ちなみにこれらの鍵や証明書 testkey.pk8testkey.x509.pem は ClockworkMod Recovery などのカスタムリカバリーで update.zip の署名チェックに用いられる鍵・証明書である。

*2:この場合,鍵・証明書ペアではなくキーストアを利用することになる。

*3:Android においても署名ファイルの名前は CERT.SF / .RSA に決め打ちにはなっていない。また,検証側では RSA だけでなく DSA もサポートするようだ。(ただし PGP はサポートしていない)

*4:マニフェストに限定された属性しか利用されていない点と,デジタル署名に RSA しか使えない点でサブセットである。

*5:逆に,update.zip においてはこの全体署名しか署名チェックされない。META-INF/CERT.RSA などはアーカイブ内に存在していなくても構わない。

*6:もちろん EOCD 自体にコメント長などを指定しておく必要がある。

*7:ZIP ファイルのコメント長部分に該当する。