Android 本地打包签名方案尝试

在一个木函早先版本,有一个挺炫酷的功能:网页转App。那么这么一个功能是怎么实现的呢?

方案预想

如果我们使用IDE开发的话,这个功能完全可以使用一个WebView去实现,至于网页对应的URL只需要在打包的时候进行配置就行了,可是并无法做到在已安装App中直接出包并签名安装,而且在手机中,并没法直接将代码编译称APK。所以猜测一个木函是将一个已有的APK进行修改,然后进行签名。

本地修改APK

如上分析,如果我们要对一个APK进行修改,dex代码部分在手机中修改自然是有难度了,但是如果对清单文件,或者是其他资源文件进行修改就比较有可能了。在Github上有找到一个Java项目apkeditor,运行了这个项目发现,这个项目可以做到修改清单文件内容,替换图片资源文件。

本地签名

签名方案落后

不过同样这个项目有其不足的地方,毕竟是5年前的项目了。在KeyHelper中有以下代码

    /**
     * 签名前缀
     * 首先用上面生成的keystore签名任意一个apk,解压出这个apk里面 META-INF/CERT.RSA 的文件
     * @throws IOException
     */
    private static void getSigPrefix() throws IOException, URISyntaxException {
        System.out.println("----------");
        String rsaFileName="CERT.RSA";
        File file = new File(ClassLoader.getSystemClassLoader().getResource(rsaFileName).toURI());
        FileInputStream fis = new FileInputStream(file);

        /**
         * RSA-keysize signature-length
         # 512         64
         # 1024        128
         # 2048        256
         */

        int same = (int) (file.length() - 64);  //当前-keysize 512

        byte[] buff = new byte[same];
        fis.read(buff, 0, same);
        fis.close();

        String string = new String(Base64.encodeBase64(buff), "UTF-8");
        System.out.println("sigPrefix  -->>  " + string);


    }

很明显,这个签名长度只是512,在SignApk中有这样一段注释

/**
 * HISTORICAL NOTE:
 * <p/>
 * Prior to the keylimepie release, SignApk ignored the signature
 * algorithm specified in the certificate and always used SHA1withRSA.
 * <p/>
 * Starting with keylimepie, we support SHA256withRSA, and use the
 * signature algorithm in the certificate to select which to use
 * (SHA256withRSA or SHA1withRSA).
 * <p/>
 * Because there are old keys still in use whose certificate actually
 * says "MD5withRSA", we treat these as though they say "SHA1withRSA"
 * for compatibility with older releases.  This can be changed by
 * altering the getAlgorithm() function below.
 */
/**
 *  原始代码见aosp项目目录 build/tools/signapk/SignApk.java
 *  如何生成privateKey 和 sigPrefix 见{@see KeyHelper}
 */

以及这样一段代码

/**
     * Add the hash(es) of every file to the manifest, creating it if
     * necessary.
     */
    private Manifest addDigestsToManifest(JarFile jar)
            throws IOException, GeneralSecurityException {
        Manifest input = jar.getManifest();
        Manifest output = new Manifest();
        Attributes main = output.getMainAttributes();
        if (input != null) {
            main.putAll(input.getMainAttributes());
        } else {
            main.putValue("Manifest-Version", "1.0");
            main.putValue("Created-By", "1.0 (Android SignApk)");
        }

        MessageDigest md_sha1 = MessageDigest.getInstance("SHA1");

        byte[] buffer = new byte[4096];
        int num;

        // We sort the input entries by name, and add them to the
        // output manifest in sorted order.  We expect that the output
        // map will be deterministic.

        TreeMap<String, JarEntry> byName = new TreeMap<String, JarEntry>();

        for (Enumeration<JarEntry> e = jar.entries(); e.hasMoreElements(); ) {
            JarEntry entry = e.nextElement();
            byName.put(entry.getName(), entry);
        }

        for (JarEntry entry : byName.values()) {
            String name = entry.getName();
            if (!entry.isDirectory() &&
                    (stripPattern == null || !stripPattern.matcher(name).matches())) {
                InputStream data = jar.getInputStream(entry);
                while ((num = data.read(buffer)) > 0) {
                    md_sha1.update(buffer, 0, num);
                }

                Attributes attr = null;
                if (input != null) attr = input.getAttributes(name);
                attr = attr != null ? new Attributes(attr) : new Attributes();
                attr.putValue("SHA1-Digest", new String(Base64.encodeBase64(md_sha1.digest()), "ASCII"));
                output.getEntries().put(name, attr);
            }
        }

        return output;
    }

并且分析了查看了签名文件的信息后

Creation date: Sep 22, 2015
Entry type: PrivateKeyEntry
Certificate chain length: 1
Certificate[1]:
Owner: CN=z, OU=z, O=z, L=shanghai, ST=shanghai, C=cn
Issuer: CN=z, OU=z, O=z, L=shanghai, ST=shanghai, C=cn
Serial number: 139a3b79
Valid from: Tue Sep 22 20:20:51 CST 2015 until: Thu Mar 29 20:20:51 CST 2125
Certificate fingerprints:
     SHA1: BD:1C:65:A3:39:E6:D1:33:C3:C5:AD:B0:A4:22:05:BE:90:F3:6C:CD
     SHA256: 92:57:56:C8:CD:EF:4F:43:E9:FD:ED:2D:13:DE:47:0C:99:94:92:94:97:30:F1:B4:52:24:C5:19:A9:AC:BC:F9
Signature algorithm name: SHA256withRSA
Subject Public Key Algorithm: 512-bit RSA key (weak)
Version: 3

Extensions: 

#1: ObjectId: 2.5.29.14 Criticality=false
SubjectKeyIdentifier [
KeyIdentifier [
0000: 9E 3B 67 C8 52 6E BA 7C   8F 6F E1 33 F0 5D F0 B8  .;g.Rn...o.3.]..
0010: 95 31 A8 28                                        .1.(
]
]

发现使用的是SHA256withRSA,512位RAS 进行签名的,而且从签名的APK来看,只是进行了V1签名,但现在V3签名都已经出来了

那么我们现在在进行签名的签名文件是怎样的呢?

Creation date: Oct 30, 2019
Entry type: PrivateKeyEntry
Certificate chain length: 1
Certificate[1]:
Owner: CN=y, OU=y, O=y, L=y, ST=y, C=y
Issuer: CN=y, OU=y, O=y, L=y, ST=y, C=y
Serial number: 693a88f7
Valid from: Wed Oct 30 20:32:02 CST 2019 until: Sun Oct 23 20:32:02 CST 2044
Certificate fingerprints:
     SHA1: 03:71:97:17:ED:B1:8B:84:BF:D3:61:AF:A1:AC:C0:22:4B:9D:E6:75
     SHA256: D5:E0:1D:B4:1E:9C:3F:8C:E4:3B:F0:B4:89:3D:44:F7:86:49:CE:C3:8B:BA:7A:14:C5:5F:3F:38:D5:6A:35:AC
Signature algorithm name: SHA256withRSA
Subject Public Key Algorithm: 2048-bit RSA key
Version: 3

以上是我们新建的签名文件,这个使用的是SHA256withRSA,2048位RAS 进行签名的。

使用新的签名方案

虽然以上的签名方案比较落后,但是他的这一段注释让我找到了方向

/**
 *  原始代码见aosp项目目录 build/tools/signapk/SignApk.java
 *  如何生成privateKey 和 sigPrefix 见{@see KeyHelper}
 */

可以从Android源码去寻找最新的签名方案,从android sdk目录下的build-tools/28.0.3/lib/里面得到apksigner.jar,这个是build-tools 24以上才有的,只支持Android 7.0 以上系统运行,支持1-3代签名技术,但是同样的如果想要从电脑里面得到jarsigner就不太可能了,因为这个是由java提供的,是一个可执行文件,不是jar文件,但是在源码中存在这个文件 /prebuilts/sdk/tools/lib/signapk.jar,这个文件可以实现在Android 7.0以下运行,进行V1 签名。
通过以上操作,得到的两个jar文件,就可以实现在Android7.0以下进行1代签名和Android 7.0 以上进行1-3代签名了

分离PK8和PEM

apksigner.jar 中有个文件help_sign.txt,里面有这样的一段使用帮助

        EXAMPLES

1. Sign an APK, in-place, using the one and only key in keystore release.jks:
$ apksigner sign --ks release.jks app.apk

1. Sign an APK, without overwriting, using the one and only key in keystore
   release.jks:
$ apksigner sign --ks release.jks --in app.apk --out app-signed.apk

3. Sign an APK using a private key and certificate stored as individual files:
$ apksigner sign --key release.pk8 --cert release.x509.pem app.apk

4. Sign an APK using two keys:
$ apksigner sign --ks release.jks --next-signer --ks magic.jks app.apk

5. Sign an APK using PKCS #11 JCA Provider:
$ apksigner sign --provider-class sun.security.pkcs11.SunPKCS11 \
    --provider-arg token.cfg --ks NONE --ks-type PKCS11 app.apk

6. Sign an APK using a non-ASCII password KeyStore created on English Windows.
   The --pass-encoding parameter is not needed if apksigner is being run on
   English Windows with Java 8 or older.
$ apksigner sign --ks release.jks --pass-encoding ibm437 app.apk

7. Sign an APK on Windows using a non-ASCII password KeyStore created on a
   modern OSX or Linux machine:
$ apksigner sign --ks release.jks --pass-encoding utf-8 app.apk

8. Sign an APK with rotated signing certificate:
$ apksigner sign --ks release.jks --next-signer --ks release2.jks \
    --lineage /path/to/signing/history/lineage app.apk

说明了使用签名的几种方案,但是为了不用输入密码,我选取了第三种方案进行签名,这样的话就需要得到PK8文件和PEM文件了
网上找了ks2x509.jar这样的一个工具,可以从一个jks签名文件提取生成PK8文件和PEM文件

signapk 准备

由于signapk.jar 的入口文件SignApk并不是public,所以我们需要使用一个同包名的类才能调用到它

package com.android.signapk;

public class ApkSignerProxy {

    public static void main(String[] args) {
        SignApk.main(args);
    }

}

写一段测试代码

一切准备就行后,使用代码测试一下

findViewById(R.id.signButton).setOnClickListener(view -> {
            File unsignFile = new File(
                    Environment.getExternalStorageDirectory(),
                    "app_debug.apk"
            );
            Log.d(TAG, "unsignFile--->" + unsignFile.getAbsolutePath());
            File outapk = new File(
                    Environment.getExternalStorageDirectory(),
                    "temp.apk"
            );
            Log.d(TAG, "outapk--->" + outapk.getAbsolutePath());
            if (outapk.exists()) {
                outapk.delete();
            }
            File pk8 = new File(
                    Environment.getExternalStorageDirectory(),
                    "testkey.pk8"
            );
            Log.d(TAG, "pk8--->" + pk8.getAbsolutePath());
            File pem = new File(
                    Environment.getExternalStorageDirectory(),
                    "testkey.x509.pem"
            );
            Log.d(TAG, "pem--->" + pem.getAbsolutePath());
            try {
                Log.d(TAG, "onClick: 签名开始");
                if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M) {
                    ApkSignerTool.main(new String[]{"sign",
                            "--key",
                            pk8.getAbsolutePath(),
                            "--cert",
                            pem.getAbsolutePath(),
                            "--v2-signing-enabled",
                            "false",
                            "--out",
                            outapk.getAbsolutePath(),
                            "--in",
                            unsignFile.getAbsolutePath()});
                } else {
                    ApkSignerProxy.main(new String[]{
                            pem.getAbsolutePath(),
                            pk8.getAbsolutePath(),
                            unsignFile.getAbsolutePath(),
                            outapk.getAbsolutePath()
                    });
                }
                Log.d(TAG, "onClick: 签名结束");
            } catch (Exception e) {
                e.printStackTrace();
            }
            while (!outapk.exists()) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            Log.d(TAG, "签名完成");
            runOnUiThread(() -> Toast.makeText(MainActivity.this, "签名完成", Toast.LENGTH_SHORT).show());
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

运行后成功的在Android6.0设备和Android8.0设备对文件进行签名

工具分享

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容