Android V1签名与校验原理分析(全网最全最详细)
Posted 潇曜
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了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-Digest
与 SHA-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
、*.DSA
、SIG-*
文件之外都参与摘要签名计算,而且也只是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.5、MANIFEST.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-name
、ks-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.2、PKCS#7
中包含了X.509
证书(密码学里公钥证书的格式标准),X.509证书格式如下
可以通过openssl
以下命令查看CERT.RSA文件中包含的所有x509证书
详情
openssl pkcs7 -inform DER -in <*.RSA文件路径> -text -noout -print_certs
显示信息如下:
3.3、PKCS#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
, 从pkcs7
的X.509
证书中读取出公钥pk
,从pkcs7
的signerInfo
中读取出签名数据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签名源码部分比较绕,所以这里对源码阅读进行一个简要的分析,以便大家快速找到自己想要阅读的部分
1、verifyCertificate
里面的包括:方法verifyBytes
(校验.SF文件摘要是否跟.RSA文件中记录的摘要值一致)、verify
(校验MANIFEST.MF文件的摘要是否.SF文件的记录一致)
2、loadCertificates
方法主要是校验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
中的每一个数据块
进行摘要计算,要是每一个数据块的摘要校验可以通过,那么签名校验依然是可以通过的。只不过这样的一个校验设计逻辑是基于什么方面的考虑呢?这个不得而知,有知道的小伙伴欢迎告知一二
以上是关于Android V1签名与校验原理分析(全网最全最详细)的主要内容,如果未能解决你的问题,请参考以下文章
全网最全JSR303参数校验与全局异常处理(从理论到实践别用if判断参数了)
全网最全Python金融大数据挖掘与分析,基础篇(附源代码,pycharm专业版无限期申请)