Android 本地打包签名方案尝试

1,789 阅读6分钟

在一个木函早先版本,有一个挺炫酷的功能:网页转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设备对文件进行签名

工具分享