Android签名过程详解

作者 : 开心源码 本文共14173个字,预计阅读时间需要36分钟 发布时间: 2022-05-11 共68人阅读

1、本文主要内容

  • 基础知识
  • 手动签名apk
  • 签名工具源码解析
  • 总结

为了保证apk的安全性,必需对apk进行签名。pms通过签名校验,确保apk没有被破坏,甚至有些权限也与签名有关。本文主要阐述签名原理以及签名过程。

2、基础知识

2.1、数据摘要

对数据源进行算法之后得到一个摘要,也叫作数据指纹,不同的数据源,数据指纹一定不一样,就和人一样

消息摘要算法(Message Digest Algorithm)是一种可以产生特殊输出格式的算法,其原理是根据肯定的运算规则对原始数据进行某种形式的信息提取,被提取出的信息就被称作原始数据的消息摘要。

著名的摘要算法有RSA公司的MD5算法和SHA-1算法及其大量的变体

消息摘要的主要特点有:

  • 无论输入的消息有多长,计算出来的消息摘要的长度总是固定的。例如应使用MD5算法摘要的消息有128个比特位,使用SHA-1算法摘要的消息最终有160比特位的输出。
  • 一般来说(不考虑碰撞的情况下),只需输入的原始数据不同,对其进行摘要以后产生的消息摘要也必不相同,即便原始数据稍有改变,输出的消息摘要便完全不同。但是,相同的输入必会产生相同的输出。
  • 具备不可逆性,即只可以进行正向的信息摘要,而无法从摘要中恢复出任何的原始消息。

2.2 签名文件和证书

签名文件和证书是成对出现了,二者不可分离

要确保可靠通信,必需要处理两个问题:

  • 要确定消息的来源的确是其申明的那个人
  • 要保证信息在传递的过程中不被第三方篡改,即便被篡改了,也能发觉出来。

所谓数字签名,就是为理解决这两个问题而产生的,它是对前面提到的非对称加密技术与数字摘要技术的一个具体的应使用。

对于消息的发送者来说,先要生成一对公私钥对,将公钥给消息的接收者。
假如消息的发送者有一天想给消息接收者发消息,在发送的信息中,除了要包含原始的消息外,还要加上另外一段消息。这段消息通过如下两步生成:

  • 对要发送的原始消息提取消息摘要;
  • 对提取的信息摘要使用自己的私钥加密。

通过这两步得出的消息,就是所谓的原始信息的数字签名。
而对于信息的接收者来说,他所收到的信息,将包含两个部分,一是原始的消息内容,二是附加的那段数字签名。他将通过以下三步来验证消息的真伪:

  • 对原始消息部分提取消息摘要,注意这里用的消息摘要算法要和发送方用的一致;
  • 对附加上的那段数字签名,用预先得到的公钥解密;
  • 比较前两步所得到的两段消息能否一致。假如一致,则表明消息的确是期望的发送者发的,且内容没有被篡改过;相反,假如不一致,则表明传送的过程中肯定出了问题,消息不可信。

通过这种所谓的数字签名技术,的确能有效处理可靠通信的问题。假如原始消息在传送的过程中被篡改了,那么在消息接收者那里,对被篡改的消息提取的摘要一定和原始的不一样。并且,因为篡改者没有消息发送方的私钥,即便他能重新算出被篡改消息的摘要,也不可以伪造出数字签名。

所以,综上所述,数字签名其实就是只有信息的发送者才可以产生的别人无法伪造的一段数字串,这段数字串同时也是对信息的发送者发送信息真实性的一个有效证实。

不知道大家有没有注意,前面讲的这种数字签名方法,有一个前提,就是消息的接收者必需要事前得到正确的公钥。假如一开始公钥就被别人篡改了,那坏人就会被你当成好人,而真正的消息发送者给你发的消息会被你视作无效的。而且,很多时候根本就不具有事前沟通公钥的信息通道。那么如何保证公钥的安全可信呢?这就要靠数字证书来处理了。

数字证书,一般包含以下少量内容:
证书的发布机构(Issuer)
证书的有效期(Validity)
消息发送方的公钥
证书所有者(Subject)
数字签名所用的算法
数字签名

能看出,数字证书其实也使用到了数字签名技术。只不过要签名的内容是消息发送方的公钥,以及少量其它信息。但与普通数字签名不同的是,数字证书中签名者不是随随意便一个普通的机构,而是要有肯定公信力的机构。一般来说,这些有公信力机构的根证书已经在设施出厂前预先安装到了你的设施上了。所以,数字证书能保证数字证书里的公钥的确是这个证书的所有者的,或者者证书能使用来确认对方的身份。数字证书主要是使用来处理公钥的安全发放问题。

综上所述,总结一下,数字签名和签名验证的大体流程如下图所示:

3、手动签名apk

签名apk有两种方法,今天我们详情signapk工具,signapk工具源码在 build/tools/signapk 中

它的使用法非常简单:java -jar signapk.jar platform.x509.pem platform.pk8 Origin.apk signed.apk,其中platform.x509.pem是公钥,platform.pk8为私钥。

apk签名完成以后,解压apk,会多出一个文件夹:META-INF,这里边存在3个文件,它们与本次签名有关。

4、签名工具源码解析

源码文件夹中只有1个java文件,SignApk.java,查看它的main方法,看看apk签名过程中究竟做了些什么事情。

public static void main(String[] args) {    if (args.length < 4) usage();    for (int i = 0; i < args.length; i++) {        System.out.println("main parms[" + i +"] = " + args[i]);    }    sBouncyCastleProvider = new BouncyCastleProvider();    Security.addProvider(sBouncyCastleProvider);    boolean signWholeFile = false;    String providerClass = null;    String providerArg = null;    int alignment = 4;    int argstart = 0;    while (argstart < args.length && args[argstart].startsWith("-")) {        if ("-w".equals(args[argstart])) {            signWholeFile = true;            ++argstart;        } else if ("-providerClass".equals(args[argstart])) {            if (argstart + 1 >= args.length) {                usage();            }            providerClass = args[++argstart];            ++argstart;        } else if ("-a".equals(args[argstart])) {            alignment = Integer.parseInt(args[++argstart]);            ++argstart;        } else {            usage();        }    }    if ((args.length - argstart) % 2 == 1) usage();    int numKeys = ((args.length - argstart) / 2) - 1;    System.out.println("argstart =" + argstart + "  numKeys = " + numKeys + " signWholeFile = " + signWholeFile);    if (signWholeFile && numKeys > 1) {        System.err.println("Only one key may be used with -w.");        System.exit(2);    }    loadProviderIfNecessary(providerClass);    String inputFilename = args[args.length-2];    String outputFilename = args[args.length-1];    JarFile inputJar = null;    FileOutputStream outputFile = null;    int hashes = 0;    try {        File firstPublicKeyFile = new File(args[argstart+0]);        X509Certificate[] publicKey = new X509Certificate[numKeys];        try {            for (int i = 0; i < numKeys; ++i) {                int argNum = argstart + i*2;                publicKey[i] = readPublicKey(new File(args[argNum]));                hashes |= getDigestAlgorithm(publicKey[i]);            }        } catch (IllegalArgumentException e) {            System.err.println(e);            System.exit(1);        }        // Set the ZIP file timestamp to the starting valid time        // of the 0th certificate plus one hour (to match what        // we've historically done).        long timestamp = publicKey[0].getNotBefore().getTime() + 3600L * 1000;        PrivateKey[] privateKey = new PrivateKey[numKeys];        for (int i = 0; i < numKeys; ++i) {            int argNum = argstart + i*2 + 1;            privateKey[i] = readPrivateKey(new File(args[argNum]));        }        inputJar = new JarFile(new File(inputFilename), false);  // Don't verify.        outputFile = new FileOutputStream(outputFilename);        if (signWholeFile) {            SignApk.signWholeFile(inputJar, firstPublicKeyFile,                                  publicKey[0], privateKey[0], outputFile);        } else {            JarOutputStream outputJar = new JarOutputStream(outputFile);            // For signing .apks, use the maximum compression to make            // them as small as possible (since they live forever on            // the system partition).  For OTA packages, use the            // default compression level, which is much much faster            // and produces output that is only a tiny bit larger            // (~0.1% on full OTA packages I tested).            outputJar.setLevel(9);            Manifest manifest = addDigestsToManifest(inputJar, hashes);            copyFiles(manifest, inputJar, outputJar, timestamp, alignment);            signFile(manifest, inputJar, publicKey, privateKey, outputJar);            outputJar.close();        }    } catch (Exception e) {        e.printStackTrace();        System.exit(1);    } finally {        try {            if (inputJar != null) inputJar.close();            if (outputFile != null) outputFile.close();        } catch (IOException e) {            e.printStackTrace();            System.exit(1);        }    }}

假如能自己编译源码的同学,能在其中加log,重新编译signapk工具,以便于自己了解签名过程。

在第3节中能看到,我们向signapk工具传了4个参数,所以main方法的args数组长度为4,参数的值大家都清楚,所以第1个while循环是不会执行的。所以,argstart 值为0,numKeys 值为1,signWholeFile 值为false。

接下来,读取公钥

for (int i = 0; i < numKeys; ++i) {     int argNum = argstart + i*2;     publicKey[i] = readPublicKey(new File(args[argNum]));     hashes |= getDigestAlgorithm(publicKey[i]);}

读取私钥

        PrivateKey[] privateKey = new PrivateKey[numKeys];        for (int i = 0; i < numKeys; ++i) {            int argNum = argstart + i*2 + 1;            privateKey[i] = readPrivateKey(new File(args[argNum]));        }

由于signWholeFile为false,所以下边的方法会被执行

private static Manifest addDigestsToManifest(JarFile jar, int hashes)         throws IOException, GeneralSecurityException {    //jar.getManifest,得到的对象与MANIFEST.MF文件相关    Manifest input = jar.getManifest();    Manifest output = new Manifest();    Attributes main = output.getMainAttributes();    if (input != null) {        //假如签名前已经被签名过一次,这是第二次签名,那么向新的MANIFEST.MF增加以前的文文件信息        main.putAll(input.getMainAttributes());    } else {        //未被签名过的apk,MANIFEST.MF的头文件信息中将增加上这样的一段话        main.putValue("Manifest-Version", "1.0");        main.putValue("Created-By", "1.0 (Android SignApk)");    }    //确定选使用哪种算法来计算数据摘要    MessageDigest md_sha1 = null;    MessageDigest md_sha256 = null;    if ((hashes & USE_SHA1) != 0) {        md_sha1 = MessageDigest.getInstance("SHA1");    }    if ((hashes & USE_SHA256) != 0) {        md_sha256 = MessageDigest.getInstance("SHA256");    }    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>();    //jar是原apk文件,它是个压缩文件,此处遍历压缩文件中的所有文件,并且加入到byName中    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())) {            //遍历原apk文件中的所有文件,排队文件夹以及命名与正则表达式不匹配的文件            InputStream data = jar.getInputStream(entry);            while ((num = data.read(buffer)) > 0) {                //文件输入流读入文件,为后续计算数据摘要做准备                if (md_sha1 != null)                    md_sha1.update(buffer, 0, num);                if (md_sha256 != null)                    md_sha256.update(buffer, 0, num);            }            Attributes attr = null;            if (input != null)                attr = input.getAttributes(name);            attr = attr != null ? new Attributes(attr) : new Attributes();            //计算文件的数据摘要,保存在attr中            if (md_sha1 != null) {                attr.putValue("SHA1-Digest", new String(Base64.encode(md_sha1.digest()), "ASCII"));            }            if (md_sha256 != null) {                attr.putValue("SHA-256-Digest", new String(Base64.encode(md_sha256.digest()), "ASCII"));            }            System.out.println("addDigestsToManifest  name = " + name + " value = " + attr.getValue("SHA-256-Digest"));            //以文件名为key,数据摘要为value,保存在output中,也就是签名后apk的MANIFEST.MF中            output.getEntries().put(name, attr);        }    }    return output;}

addDigestsToManifest方法遍历apk中的所有文件(解压apk),除文件夹和META-INF文件夹除外,计算这些文件的数据摘要值并且保存在 Manifest 对象中。

执行完addDigestsToManifest方法后,继续看看copyFiles方法:

for (String name : names) {        JarEntry inEntry = in.getJarEntry(name);        JarEntry outEntry = null;        if (inEntry.getMethod() == JarEntry.STORED) continue;        // Create a new entry so that the compressed len is recomputed.        outEntry = new JarEntry(name);        outEntry.setTime(timestamp);        out.putNextEntry(outEntry);        InputStream data = in.getInputStream(inEntry);        while ((num = data.read(buffer)) > 0) {            out.write(buffer, 0, num);        }        out.flush();    }

copyFiles方法比较简单,它有两段相似上面的代码,复制原apk文件到新的签名后的apk文件当中,就是写文件。

继续看signFile方法:

private static void signFile(Manifest manifest, JarFile inputJar,        X509Certificate[] publicKey, PrivateKey[] privateKey,        JarOutputStream outputJar) throws Exception {    // Assume the certificate is valid for at least an hour.    long timestamp = publicKey[0].getNotBefore().getTime() + 3600L * 1000;    // MANIFEST.MF    //将addDigestsToManifes方法得到的manifest数组写入到MANIFEST.MF文件当中    JarEntry je = new JarEntry(JarFile.MANIFEST_NAME);    je.setTime(timestamp);    outputJar.putNextEntry(je);    manifest.write(outputJar);    int numKeys = publicKey.length;    for (int k = 0; k < numKeys; ++k) {        // CERT.SF / CERT#.SF        je = new JarEntry(numKeys == 1 ? CERT_SF_NAME : (String.format(                CERT_SF_MULTI_NAME, k)));        je.setTime(timestamp);        outputJar.putNextEntry(je);        ByteArrayOutputStream baos = new ByteArrayOutputStream();        //确定CERT.SF文件的内容        writeSignatureFile(manifest, baos, getDigestAlgorithm(publicKey[k]));        byte[] signedData = baos.toByteArray();        //将输出流写入到CERT.SF文件当中        outputJar.write(signedData);        // CERT.{EC,RSA} / CERT#.{EC,RSA}        //CERT.RSA文件相关        final String keyType = publicKey[k].getPublicKey().getAlgorithm();        je = new JarEntry(numKeys == 1 ? (String.format(CERT_SIG_NAME,                keyType))                : (String.format(CERT_SIG_MULTI_NAME, k, keyType)));        je.setTime(timestamp);        outputJar.putNextEntry(je);        //往CERT.RSA写入        writeSignatureBlock(new CMSProcessableByteArray(signedData),                publicKey[k], privateKey[k], outputJar);    }}

在addDigestsToManifest方法中得到的Manifest 对象将在signFile方法中写入MANIFEST.MF文件当中。

JarEntry je = new JarEntry(JarFile.MANIFEST_NAME);manifest.write(outputJar);//JarFile类常量public static final String MANIFEST_NAME = "META-INF/MANIFEST.MF";

由于这个JarEntry 对应着MANIFEST.MF文件,查看manifest的write方法,即为写文件,可知最后写入MANIFEST.MF是在此处完成。查看META-INF/MANIFEST.MF文件,发现其内容正是记录apk各文件的数据摘要:

接下来继续看对CERT.SF文件的解决:

/** Write a .SF file with a digest of the specified manifest. */private static void writeSignatureFile(Manifest manifest, OutputStream out,        int hash) throws IOException, GeneralSecurityException {    Manifest sf = new Manifest();    Attributes main = sf.getMainAttributes();    main.putValue("Signature-Version", "1.0");    main.putValue("Created-By", "1.0 (Android SignApk)");    MessageDigest md = MessageDigest            .getInstance(hash == USE_SHA256 ? "SHA256" : "SHA1");    PrintStream print = new PrintStream(new DigestOutputStream(            new ByteArrayOutputStream(), md), true, "UTF-8");    // Digest of the entire manifest    //为MANIFEST.MF文件内容做一次数据摘要,并保存在sf对象中    manifest.write(print);    print.flush();    main.putValue(hash == USE_SHA256 ? "SHA-256-Digest-Manifest"            : "SHA1-Digest-Manifest", new String(            Base64.encode(md.digest()), "ASCII"));    Map<String, Attributes> entries = manifest.getEntries();    for (Map.Entry<String, Attributes> entry : entries.entrySet()) {        // Digest of the manifest stanza for this entry.        //遍历MANIFEST.MF文件中的所有数据        print.print("Name: " + entry.getKey() + "\r\n");        for (Map.Entry<Object, Object> att : entry.getValue().entrySet()) {            print.print(att.getKey() + ": " + att.getValue() + "\r\n");        }        print.print("\r\n");        print.flush();        //为MANIFEST.MF文件内的每一条数据重新做数据摘要并保存在sf中        Attributes sfAttr = new Attributes();        sfAttr.putValue(hash == USE_SHA256 ? "SHA-256-Digest"                : "SHA1-Digest-Manifest",                new String(Base64.encode(md.digest()), "ASCII"));        sf.getEntries().put(entry.getKey(), sfAttr);    }    //将sf写入到cout的输出流当中    CountOutputStream cout = new CountOutputStream(out);    sf.write(cout);    // A bug in the java.util.jar implementation of Android platforms    // up to version 1.6 will cause a spurious IOException to be thrown    // if the length of the signature file is a multiple of 1024 bytes.    // As a workaround, add an extra CRLF in this case.    if ((cout.size() % 1024) == 0) {        cout.write('\r');        cout.write('\n');    }}

Manifest对象是一种特殊的数据结构,它以键值对形式保存数据。在方法开头,新建一个Manifest对象,而后写入相似于头文件信息,而后再对MANIFEST.MF文件整体做一次数据摘要,并且保存到Manifest对象中,我们来看看CERT.SF文件的内容

紧接着,将遍历MANIFEST.MF文件中的所有条目内容,并且对这些条目内容重新计算数据摘要并保存到Manifest对象中

for (Map.Entry<String, Attributes> entry : entries.entrySet()) {        // Digest of the manifest stanza for this entry.        //遍历MANIFEST.MF文件中的所有数据        print.print("Name: " + entry.getKey() + "\r\n");        for (Map.Entry<Object, Object> att : entry.getValue().entrySet()) {            print.print(att.getKey() + ": " + att.getValue() + "\r\n");        }        print.print("\r\n");        print.flush();        //为MANIFEST.MF文件内的每一条数据重新做数据摘要并保存在sf中        Attributes sfAttr = new Attributes();        sfAttr.putValue(hash == USE_SHA256 ? "SHA-256-Digest"                : "SHA1-Digest-Manifest",                new String(Base64.encode(md.digest()), "ASCII"));        sf.getEntries().put(entry.getKey(), sfAttr);    }

最后将Manifest写入输出流中,回到sign方法后,最终此输出流将内容写入到CERT.SF文件当中。

最后来查看下对CERT.RSA文件的解决

final String keyType = publicKey[k].getPublicKey().getAlgorithm();        je = new JarEntry(numKeys == 1 ? (String.format(CERT_SIG_NAME,                keyType))                : (String.format(CERT_SIG_MULTI_NAME, k, keyType)));        je.setTime(timestamp);        outputJar.putNextEntry(je);        // 往CERT.RSA写        writeSignatureBlock(new CMSProcessableByteArray(signedData),                publicKey[k], privateKey[k], outputJar);

注意,signdata是要写入CERT.SF文件的内容,将signdata、公钥、私钥以及一个输出流传到writeSignatureBlock方法中,接下来看看这个方法

private static void writeSignatureBlock(CMSTypedData data,        X509Certificate publicKey, PrivateKey privateKey, OutputStream out)        throws IOException, CertificateEncodingException,        OperatorCreationException, CMSException {    //解决公钥相关    ArrayList<X509Certificate> certList = new ArrayList<X509Certificate>(1);    certList.add(publicKey);    JcaCertStore certs = new JcaCertStore(certList);    //解决私钥相关    CMSSignedDataGenerator gen = new CMSSignedDataGenerator();    ContentSigner signer = new JcaContentSignerBuilder(            getSignatureAlgorithm(publicKey)).setProvider(            sBouncyCastleProvider).build(privateKey);    gen.addSignerInfoGenerator(new JcaSignerInfoGeneratorBuilder(            new JcaDigestCalculatorProviderBuilder().setProvider(                    sBouncyCastleProvider).build())            .setDirectSignature(true).build(signer, publicKey));    gen.addCertificates(certs);    //用生成的公钥私钥工具,对data数据进行加密    CMSSignedData sigData = gen.generate(data, false);    //将加密的数据生成一个二进制输入流对象    ASN1InputStream asn1 = new ASN1InputStream(sigData.getEncoded());    DEROutputStream dos = new DEROutputStream(out);    //输出流写入文件CERT.RSA中    dos.writeObject(asn1.readObject());}

原来,CERT.RSA文件中保存的是用公钥私钥加密过的CERT.SF文件内容。尽管这段代码看起来很复杂,对于加密相关的东西一点也不懂,但假如可以抓住关键内容,仍然可以看懂。事实上,CERT.RSA的确是一个二进制文件,要使用查看二进制文本的工具才可以打开查看。

总结

apk签名过程最重要的三个步骤:

  • 对Apk中的每个文件做一次算法(数据摘要+Base64编码),保存到MANIFEST.MF文件中
  • 对MANIFEST.MF整个文件做一次算法(数据摘要+Base64编码),存放到CERT.SF文件的头属性中,在对MANIFEST.MF文件中各个属性块做一次算法(数据摘要+Base64编码),存到到一个属性块中。
  • 对CERT.SF文件做签名,内容存档到CERT.RSA中

那么,为什么要这么设计签名流程呢?

首先,假如你改变了apk包中的任何文件,那么在apk安装校验时,改变后的文件摘要信息与MANIFEST.MF的检验信息不同,于是验证失败,程序就不可以成功安装。

其次,假如你对更改的过的文件相应的算出新的摘要值,而后更改MANIFEST.MF文件里面对应的属性值,那么必定与CERT.SF文件中算出的摘要值不一样,照样验证失败。

最后,假如你还不死心,继续计算MANIFEST.MF的摘要值,相应的更改CERT.SF里面的值,那么数字签名值必定与CERT.RSA文件中记录的不一样,还是失败。

那么可以不可以继续伪造数字签名呢?不可可以,由于没有数字证书对应的私钥。
所以,假如要重新打包后的应使用程序可以再Android设施上安装,必需对其进行重签名。

从上面的分析能得出,只需修改了Apk中的任何内容,就必需重新签名,不然会提醒安装失败,当然这里不会分析,后面一篇文章会注重分析为何会提醒安装失败。

最后感谢尼古拉斯赵四提供这么好的材料让大家学习,本人受益良多,谢谢

说明
1. 本站所有资源来源于用户上传和网络,如有侵权请邮件联系站长!
2. 分享目的仅供大家学习和交流,您必须在下载后24小时内删除!
3. 不得使用于非法商业用途,不得违反国家法律。否则后果自负!
4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解!
5. 如有链接无法下载、失效或广告,请联系管理员处理!
6. 本站资源售价只是摆设,本站源码仅提供给会员学习使用!
7. 如遇到加密压缩包,请使用360解压,如遇到无法解压的请联系管理员
开心源码网 » Android签名过程详解

发表回复