Android V1签名与校验原理分析(全网最全最详细)

【前言】

     Android Apk V1签名方式是一开始时使用的签名方案,不过V1签名方式也称作Jar签名,顾名思义,就是V1签名并不是Android独有的签名方式,而且在Android还没出来时候,Jar 包也是用这种方式进行签名检验的,直到Android 7.0开始才推出V2签名,这个就是Android独创的签名方案,签名与校验的效率方面提高很多,后面Android 9.0又推出了V3签名,再到Android 11推出了V4签名方案

一、V1签名过程分析

在这里插入图片描述

1、MANIFEST.MF

     遍历Apk中除了META-INF目录下以下文件之外的所有文件,

META-INF/MANIFEST.MF
META-INF/*.SF
META-INF/*.DSA
META-INF/*.RSA
META-INF/SIG-*

并对他们逐一使用SHA1或者SHA256算法,计算出摘要值,Base64之后保存到 MANIFEST.MF文件中,
最终 MANIFEST.MF文件内容大致如下:
在这里插入图片描述
1.1、前面3行是主属性记录

Manifest-Version: 1.0
Built-By: Signflinger
Created-By: Android Gradle 4.1.2

1.2、 其中每个文件摘要前的SHA-256-Digest,这个由签名apk时所用到签名文件的签名算法以及apk本身适配的最小SDK版本号共同决定,取值可能是SHA-1-DigestSHA-256-Digest,下面是签名工具的代码实现部分:

  public static DigestAlgorithm getSuggestedSignatureDigestAlgorithm(PublicKey signingKey, int minSdkVersion) throws InvalidKeyException {
        String keyAlgorithm = signingKey.getAlgorithm();
        if ("RSA".equalsIgnoreCase(keyAlgorithm)) {

            if (minSdkVersion < 18) {
                return DigestAlgorithm.SHA1;
            }
            return DigestAlgorithm.SHA256;
        }
        if ("DSA".equalsIgnoreCase(keyAlgorithm)) {

            if (minSdkVersion < 21) {
                return DigestAlgorithm.SHA1;
            }
            return DigestAlgorithm.SHA256;
        }
        if ("EC".equalsIgnoreCase(keyAlgorithm)) {
            if (minSdkVersion < 18) {
                throw new InvalidKeyException("ECDSA signatures only supported for minSdkVersion 18 and higher");
            }

            return DigestAlgorithm.SHA256;
        }
        throw new InvalidKeyException("Unsupported key algorithm: " + keyAlgorithm);
    }

1.3、上面说到META目录下除了MENIFEST.MF*.SF*.RSA*.DSASIG-* 文件之外都参与摘要签名计算,而且也只是META根目录的这些文件不参与计算签名, 在META目录的子目录中的所有文件都是参与签名的,看下图也可得知:
在这里插入图片描述
1.4、遍历对apk中的文件解压之后,再利用SHA1或者SHA256算法计算摘要签名,然后Base64之后保存到MENIFEST.MF中,主要代码实现逻辑如下:

	//解压并更新摘要类
    private static class InflateSinkAdapter
            implements DataSink, Closeable {
        private final DataSink mDelegate;

    	.....

        public void consume(byte[] buf, int offset, int length) throws IOException {
            checkNotClosed();
            this.mInflater.setInput(buf, offset, length);
            if (this.mOutputBuffer == null) {
                this.mOutputBuffer = new byte[65536];
            }
            while (!this.mInflater.finished()) {
                int outputChunkSize;
                try {
                    //对文件进行解压,outputChunkSize为解压之后的大小,mOutputBuffer保存解压之后的数据
                    outputChunkSize = this.mInflater.inflate(this.mOutputBuffer);
                } catch (DataFormatException e) {
                    throw new IOException("Failed to inflate data", e);
                }
                if (outputChunkSize == 0) {
                    return;
                }
                //mDelegate为MessageDigestSink对象,对解压之后的文件进行摘要签名更新
                this.mDelegate.consume(this.mOutputBuffer, 0, outputChunkSize);
                this.mOutputByteCount += outputChunkSize;
            }
        }
        .....
    }

	// 摘要数据更新类
    public class MessageDigestSink implements DataSink {
    	private final MessageDigest[] mMessageDigests;

    	public MessageDigestSink(MessageDigest[] digests) {
        	this.mMessageDigests = digests;
    	}


    	public void consume(byte[] buf, int offset, int length) {
        	for (MessageDigest md : this.mMessageDigests) {
            	md.update(buf, offset, length);
        	}
    	}


    	public void consume(ByteBuffer buf) {
        	int originalPosition = buf.position();
        	for (MessageDigest md : this.mMessageDigests) {
            	buf.position(originalPosition);
            	md.update(buf);
        	}
    	}   
	}

	//计算摘要
    private static void fulfillInspectInputJarEntryRequest(DataSource lfhSection, LocalFileRecord localFileRecord, ApkSignerEngine.InspectJarEntryRequest inspectEntryRequest) throws IOException, ZipFormatException {
        //解压本地文件数据出来并放入到MessageDigestSink中
        localFileRecord.outputUncompressedData(lfhSection, inspectEntryRequest.getDataSink());
        // 计算出文件数据的摘要签名
        inspectEntryRequest.done();

    }

1.5MANIFEST.MF的行最长只允许70个字符,这里面包括:Name 与 文件名中间的冒号与空格(不包括\r\n,加上回车换行符共72个字符),要是超出70个字符就回车换行,然后在新行先写入1个空格,再继续写入剩下的文件名,代码实现如下:

	private static final byte[] CRLF = new byte[]{13, 10};
    private static final int MAX_LINE_LENGTH = 70;
    
   	private static void writeAttribute(OutputStream out, String name, String value) throws IOException {
        writeLine(out, name + ": " + value);
    }

    
    private static void writeLine(OutputStream out, String line) throws IOException {
        byte[] lineBytes = line.getBytes(StandardCharsets.UTF_8);
        int offset = 0;
        int remaining = lineBytes.length;
        boolean firstLine = true;
        while (remaining > 0) {
            int chunkLength;
            if (firstLine) {
                //一行最高70个字符,超过70个就换行显示
                chunkLength = Math.min(remaining, MAX_LINE_LENGTH);
            } else {
                //回车换行
                out.write(CRLF);
                //空格
                out.write(32);
                //因为这一行多了1个空格,所以最多只能69个字符
                chunkLength = Math.min(remaining, 69);
            }
            out.write(lineBytes, offset, chunkLength);
            offset += chunkLength;
            remaining -= chunkLength;
            firstLine = false;
        }
        //末尾回车换行
        out.write(CRLF);
    }

1.6、因为每次写入1个数据块就写入2对回车换行符,所以在MANIFEST.MF末尾会有2个空行,下面看看每次写入1个数据块的代码实现:

	//写入数据块
    public static void writeIndividualSection(OutputStream out, String name, Attributes attributes) throws IOException {
    	//写入类似:Name: AndroidManifest.xml\r\n
        writeAttribute(out, "Name", name);

        if (!attributes.isEmpty()) {
            //写入类似:SHA1-Digest: tJkLYKjlAku97m4hDC7yxlJK4XA=\r\n
            writeAttributes(out, getAttributesSortedByName(attributes));
        }
        //写入:\r\n
        writeSectionDelimiter(out);
    }

	// 写入回车换行符
    static void writeSectionDelimiter(OutputStream out) throws IOException {
        out.write(CRLF);
    }

2、CERT.SF

     .SF文件名是由签名时候所传入的参数v1-signer-nameks-key-alias或者keystore文件名所决定,不是固定为CERT.SF,.SF文件的主要作用是对MANIFEST.MF做校验,防止MANIFEST.MF的数据被篡改。.SF文件主要保存了MANIFEST.MF整个文件的签名摘要信息以及每一个数据块的签名摘要信息,信息如下:
在这里插入图片描述
2.1、第一个数据块是.SF文件的主属性信息

Signature-Version: 1.0
Created-By: 1.0 (Android)
SHA-256-Digest-Manifest: RF3bTyfX9uHZesEx91eumLw7u4EYS1cMc5emxi0npso=
X-Android-APK-Signed: 2, 3

其中,SHA-256-Digest-Manifest这个属性的值是对MANIFEST.MF整个文件SHA-256散列算法计算出的摘要Base64的值;
X-Android-APK-Signed,这个属性指定是否开启比V1更高级的签名方式,这里值为2,3,说明开启了V2、V3签名,那么应用安装时候,假如跳过V2、V3签名验证(即破坏或者去掉V2、V3签名信息), 直接去验证V1就会抛异常,这个是为了防止降级验证

2.2、有些签名工具还会在.SF主属性中写入SHA-256-Digest-Manifest-Main-Attributes,这个属性的值是MANIFEST.MF主属性块摘要Base64的值,验证签名的时候会优先验证这一块的摘要,只有验证通过之后才去验证整个MANIFEST.MF文件的数据摘要;对于数据块这个概念定义需要注意一下,下面这样一整块是属于MANIFEST.MF的一个主属性块,一起参与摘要计算:

Manifest-Version: 1.0
Created-By: 1.8.0_161 (Oracle Corporation)

上面把回车换行符显式表示出来的话,实际是这样:Manifest-Version: 1.0\r\nCreated-By: 1.8.0_161 (Oracle Corporation)\r\n\r\n,那么对这一整块进行SHA-256算法计算得到值为:
在这里插入图片描述
可以用以下代码计算:

 public static void main(String[] args) {
        String data= "Manifest-Version: 1.0\r\nCreated-By: 1.8.0_161 (Oracle Corporation)\r\n\r\n";
        MessageDigest md;
        try {
            md = MessageDigest.getInstance("SHA-256");
            byte[] digest = md.digest(data.getBytes("utf-8"));
            String base64Digest = Base64.getEncoder().encodeToString(digest);
            System.out.println("\n******************** 计算结果 ******************** ");
            System.out.println(base64Digest);
            System.out.println("************************************************** ");
        } catch (Exception e) {

        }

    }

这里一定要注意的是:计算摘要时候,一定要把最后的两个回车换行符一起参与计算,后面各个文件对应的摘要数据块亦是如此,来看看签名工具计算出来记录在.SF文件的数值:
在这里插入图片描述
由于一行超过了70个字符,所以SHA-256-Digest-Manifest-Main-Attributes这一行换行了,删除空格跟换行之后,跟我们计算出来的值是一致的

2.3、接下来就是对各个文件摘要数据块的进行摘要签名计算,计算方式跟主属性摘要计算一样,比如对于MANIFEST.MF下图这一文件摘要数据块:
在这里插入图片描述
字符串表示为:Name: res/drawable-mdpi/ic_currency_mad.png\r\nSHA-256-Digest: tFS4pZtxah1Uc84XRqsMhYVcBxN0bdI9PKinhLj79UA=\r\n\r\n, 用SHA-256算出摘要再Base64的结果如下:
在这里插入图片描述
看看.SF文件对应的值,的确也是一致的
在这里插入图片描述

3、CERT.RSA

     CERT.RSA文件名也不是固定的,命名规则跟.SF文件一样,而且后缀名也根据不同的签名算法,取不同的后缀名:.DSA.RSA.EC

3.1、CERT.RSA文件实际上是PKCS#7格式的数据经过DER规则编码之后的二进制文件

PKCS#7,即密码消息语法标准(Cryptographic Message Syntax Standard),是公钥加密标准(Public Key Cryptography Standards, PKCS)的1.5版本,数据格式大致如下:
在这里插入图片描述 DER(Distinguished Encoding Rules),即可分辨编码规则,是ASN.1标准(Abstract Syntax Notation One,抽象语法标记)的一种编码规则 ContentInfo这个字段,理论上来说是存放待签名内容,在这里的话,也就是对应.SF文件数据,但是因为可以直接去读取.SF文件数据来进行签名校验,所以实际上ContentInfo并没有保存.SF文件数据

3.2PKCS#7中包含了X.509证书(密码学里公钥证书的格式标准),X.509证书格式如下
在这里插入图片描述
可以通过openssl以下命令查看CERT.RSA文件中包含的所有x509证书详情

openssl pkcs7 -inform DER -in <*.RSA文件路径> -text -noout -print_certs

显示信息如下:
在这里插入图片描述
3.3PKCS#7中包含的签名者信息SignerInfo数据结构如下:
在这里插入图片描述
其中,EncryptedDigest中存储的就是*.SF文件数据SHA-1或者SHA-256算法算出的摘要值,然后用私钥签名之后的数据
看看运行时signerInfo对象:
在这里插入图片描述打印出来的数据如下:
在这里插入图片描述

二、V1签名校验过程分析

1、先在META-INF目录下查找后缀名为.DSA.RSA.EC 的签名文件,找到之后,根据签名文件的文件名推出.SF文件的文件名,比如:找到META-INF目录下文件名为:KK.RSA的签名文件, 那么,可以推出.SF文件的文件名为:KK.SF

2、读取.RSA签名文件数据,构造出PKCS#7格式的对象pkcs7, 从pkcs7X.509证书中读取出公钥pk,从pkcs7signerInfo中读取出签名数据encryptedDigest,然后用公钥pk签名数据encryptedDigest进行解密得到摘要数据digest, 读取.SF文件数据然后计算摘要得到摘要数据sfDigest,最后比对摘要数据sfDigest摘要数据digest是否相等,如果相等,说明.SF文件没有被篡改,否则签名校验失败

3、假如.SF文件中 Created-By的属性值不存在:signtool字符串,同时SHA-256-Digest-Manifest-Main-Attributes(或SHA-1-Digest-Manifest-Main-Attributes)的属性值存在,那么先校验MANIFEST.MF的主属性数据块的摘要是否跟SHA-256-Digest-Manifest-Main-Attributes属性值相等,相等的话才继续进行下一步的校验,否则签名校验失败

4、计算MANIFEST.MF整个文件的摘要值,跟.SF文件记录的SHA-256-Digest-Manifest-Main-Attributes对应的值比较,假如相等,那么可以肯定MANIFEST.MF文件没有被篡改,否则需要进一步对MANIFEST.MF文件中的每一个数据块进行计算摘要值,然后跟.SF文件中记录的摘要值进行比对,如果每一个数据块的摘要值都相等才进行下一步的校验,否则签名校验失败

5、先读取AndroidManifest.xml文件数据计算出摘要值,跟MANIFEST.MF中记录的摘要值比对,如果相等,继续遍历所有文件并计算出摘要值跟MANIFEST.MF中记录的摘要值比对,否则签名校验失败
在这里插入图片描述

三、V1签名校验过程源码分析

     因为V1签名源码部分比较绕,所以这里对源码阅读进行一个简要的分析,以便大家快速找到自己想要阅读的部分
在这里插入图片描述

1verifyCertificate里面的包括:方法verifyBytes(校验.SF文件摘要是否跟.RSA文件中记录的摘要值一致)、verify(校验MANIFEST.MF文件的摘要是否.SF文件的记录一致)

2loadCertificates方法主要是校验apk内的文件计算出的摘要值是否跟MANIFEST.MF中记录的一致,其中,计算摘要的详细实现是在read方法中,从上图可以看出,read方法最终会调用MessageDigest#digest方法计算出文件的摘要值,然后调用verifyMessageDigest方法比对计算出来的摘要值跟MANIFEST.MF中记录的是否一致

【扩展问题】

     V1签名的主要目的是为了防止apk内的文件被篡改,在整个签名过程中,我们可以看到先对Apk内每个文件计算摘要记录到MANIFEST.MF中,然后又对MANIFEST.MF整个文件以及每个数据块计算摘要记录到.SF中,最后再对.SF整个文件计算摘要并用私钥签名记录到.RSA中,那么这个过程就会有一个疑问,为啥要多此一举去创建一个.SF文件呢?直接对MANIFEST.MF整个文件计算摘要并用私钥签名记录到.RSA中,不是一样可以达到防止篡改的目的吗?从签名校验的过程中分析可以得知,.SF存在的意义应该是在对MANIFEST.MF整个文件的摘要值校验失败时,可以再对MANIFEST.MF中的每一个数据块进行摘要计算,要是每一个数据块的摘要校验可以通过,那么签名校验依然是可以通过的。只不过这样的一个校验设计逻辑是基于什么方面的考虑呢?这个不得而知,有知道的小伙伴欢迎告知一二
在这里插入图片描述

来源url
栏目