1. 概要
如果进行过OTA升级的开发者,都或多或少有这样的疑问,如何确定该OTA升级包是可以信任的呢?这其中其实涉及到一个签名验证的流程。
2. 签名生成
在生成正规的固件时,一般会运行生成新key的脚本,并重新修改key中的信息。以网上常用的生成key的脚本为例:
#!/bin/sh
AUTH='/C=CN/ST=xxxx/L=xxxxx/O=xxxxxx/OU=xxxxx/CN=China/emailAddress=xxxxxxx@com'
openssl genrsa -3 -out $1.pem 2048
openssl req -new -x509 -key $1.pem -out $1.x509.pem -days 10000 \
-subj "$AUTH"
echo "Please enter the password for this key:"
openssl pkcs8 -in $1.pem -topk8 -outform DER -out $1.pk8 -passout stdin
其中openssl通过genrsa标准命令生成私钥,默认大小为2048:
openssl genrsa -3 -out $1.pem 2048
生成私钥后,生成证书签署请求,即公钥:
openssl req -new -x509 -key $1.pem -out $1.x509.pem -days 365 \
-subj "$AUTH"
-
-new
: new request -
-x509
: output a x509 structure instead of a cert. req.该选项说明生成一个自签名的证书。 -
-key
file: use the private key contained in file -
-days
: number of days a certificate generated by -x509 is valid for.
最后通过私钥pem文件生成PKCS8私钥文件
openssl pkcs8 -in $1.pem -topk8 -outform DER -out $1.pk8 -passout stdin
也可以参考android原生的生成key的流程,位于android/development/tools下的make_key脚本i。
自此后,生成了一对公私钥用于签名校验。
3. sign_target_files_apks.py
3.1 对Apk进行重签名流程
sign_target_files_apks
在本人之前的用法都局限于将targetfile里的apk进行重签名,其流程如下:
1.获取脚本输入参数
2.获取输入文件以及输出文件参数,读取misc_info
文件
input_zip = zipfile.ZipFile(args[0], "r")
output_zip = zipfile.ZipFile(args[1], "w")
misc_info = common.LoadInfoDict(input_zip)
misc_info
记录了一些参数,如下:
其中这里关注的是默认签名的路径:
default_system_dev_certificate=build/target/product/security/testkey
3.建立key映射
假如在调用脚本时未指定-d.-k
参数,那么默认使用的正是系统自带的testkey。否则,将会映射到指定key目录下的Key
def BuildKeyMap(misc_info, key_mapping_options):
for s, d in key_mapping_options:
if s is None: # -d option
devkey = misc_info.get("default_system_dev_certificate",
"build/target/product/security/testkey")
devkeydir = os.path.dirname(devkey)
OPTIONS.key_map.update({
devkeydir + "/testkey": d + "/releasekey",
devkeydir + "/devkey": d + "/releasekey",
devkeydir + "/media": d + "/media",
devkeydir + "/shared": d + "/shared",
devkeydir + "/platform": d + "/platform",
})
else:
OPTIONS.key_map[s] = d
4.读取targetfile中的证书文件
apk_key_map = GetApkCerts(input_zip)
GetApkCerts的实现如下,其实质是读取了targetfile中/META/apkcerts.txt文件
def GetApkCerts(tf_zip):
certmap = common.ReadApkCerts(tf_zip)
# apply the key remapping to the contents of the file
for apk, cert in certmap.iteritems():
certmap[apk] = OPTIONS.key_map.get(cert, cert)
# apply all the -e options, overriding anything in the file
for apk, cert in OPTIONS.extra_apks.iteritems():
if not cert:
cert = "PRESIGNED"
certmap[apk] = OPTIONS.key_map.get(cert, cert)
return certmap
def ReadApkCerts(tf_zip):
"""Given a target_files ZipFile, parse the META/apkcerts.txt file
and return a {package: cert} dict."""
certmap = {}
for line in tf_zip.read("META/apkcerts.txt").split("\n"):
line = line.strip()
if not line:
continue
m = re.match(r'^name="(.*)"\s+certificate="(.*)"\s+'
r'private_key="(.*)"$', line)
if m:
name, cert, privkey = m.groups()
public_key_suffix_len = len(OPTIONS.public_key_suffix)
private_key_suffix_len = len(OPTIONS.private_key_suffix)
if cert in SPECIAL_CERT_STRINGS and not privkey:
certmap[name] = cert
elif (cert.endswith(OPTIONS.public_key_suffix) and
privkey.endswith(OPTIONS.private_key_suffix) and
cert[:-public_key_suffix_len] == privkey[:-private_key_suffix_len]):
certmap[name] = cert[:-public_key_suffix_len]
else:
raise ValueError("failed to parse line from apkcerts.txt:\n" + line)
return certmap
在这里可以分析下apkcerts文件,其内容格式如下:
name="RecoveryLocalizer.apk" certificate="build/target/product/security/testkey.x509.pem" private_key="build/target/product/security/testkey.pk8"
name="CtsVerifier.apk" certificate="build/target/product/security/testkey.x509.pem" private_key="build/target/product/security/testkey.pk8"
....
ame="CtsShimPrivUpgradePrebuilt.apk" certificate="PRESIGNED" private_key=""
name="CtsShimPrivUpgradeWrongSHAPrebuilt.apk" certificate="PRESIGNED" private_key=""
...
可以看出每一个apk中,都指定了证书的位置以及私钥文件路径,可以对比出,当应用的Android.mk中以platform签名,其格式为:
LOCAL_CERTIFICATE := platform
name="HdmiCts.apk" certificate="build/target/product/security/platform.x509.pem" private_key="build/target/product/security/platform.pk8"
一般apk以presigned签名的,则为:
LOCAL_CERTIFICATE := PRESIGNED
name="AllCast.apk" certificate="PRESIGNED" private_key=""
以media签名的,则为:
LOCAL_CERTIFICATE := media
name="Gallery.apk" certificate="build/target/product/security/media.x509.pem" private_key="build/target/product/security/media.pk8"
所以该文件定义了每个文件的证书以及签名情况,如果回到编译系统,可以看出该文件的编译规则:
APKCERTS_FILE := $(intermediates)/$(name).txt
$(APKCERTS_FILE):
@echo APK certs list: $@
@mkdir -p $(dir $@)
@rm -f $@
$(foreach p,$(PACKAGES),\
$(if $(PACKAGES.$(p).EXTERNAL_KEY),\
$(call _apkcerts_echo_with_newline,\
'name="$(p).apk" certificate="EXTERNAL" \
private_key=""' >> $@),\
$(call _apkcerts_echo_with_newline,\
'name="$(p).apk" certificate="$(PACKAGES.$(p).CERTIFICATE)" \
private_key="$(PACKAGES.$(p).PRIVATE_KEY)"' >> $@)))
# In case value of PACKAGES is empty.
$(hide) touch $@
.PHONY: apkcerts-list
apkcerts-list: $(APKCERTS_FILE)
可以看出编译伪目标apkcerts-list时,编译系统就会遍历$(PACKAGES),并将apk的信息记录在apkcerts.txt文档里。
在编译每一个Apk时,package_internal.mk
会读取LOCAL_CERTIFICATE
参数,并记录信息如下:
PACKAGES.$(LOCAL_PACKAGE_NAME).PRIVATE_KEY := $(private_key)
PACKAGES.$(LOCAL_PACKAGE_NAME).CERTIFICATE := $(certificate)
在编译targetfiles的时候,会编译该文件:
$(BUILT_TARGET_FILES_PACKAGE): \
$(INSTALLED_BOOTIMAGE_TARGET) \
$(INSTALLED_RADIOIMAGE_TARGET) \
$(INSTALLED_RECOVERYIMAGE_TARGET) \
$(INSTALLED_SYSTEMIMAGE) \
$(INSTALLED_USERDATAIMAGE_TARGET) \
$(INSTALLED_CACHEIMAGE_TARGET) \
$(INSTALLED_VENDORIMAGE_TARGET) \
$(INSTALLED_ANDROID_INFO_TXT_TARGET) \
$(SELINUX_FC) \
$(APKCERTS_FILE) \
$(HOST_OUT_EXECUTABLES)/fs_config \
| $(ACP)
5.处理targetfile中文件
对于targetfile中文件,核心部分调用如下方法:
ProcessTargetFiles(input_zip, output_zip, misc_info,
apk_key_map, key_passwords,
platform_api_level,
codename_to_api_level_map)
主要关心Apk部分:
for info in input_tf_zip.infolist():
if info.filename.startswith("IMAGES/"):
continue
...
# Sign APKs.
if info.filename.endswith(".apk"):
name = os.path.basename(info.filename)
key = apk_key_map[name]
if key not in common.SPECIAL_CERT_STRINGS:
print " signing: %-*s (%s)" % (maxsize, name, key)
signed_data = SignApk(data, key, key_passwords[key], platform_api_level,
codename_to_api_level_map)
common.ZipWriteStr(output_tf_zip, out_info, signed_data)
else:
# an APK we're not supposed to sign.
print "NOT signing: %s" % (name,)
common.ZipWriteStr(output_tf_zip, out_info, data)
...
只要apk的key不是以"PRESIGNED"或者"EXTERNAL"签名的,都会去重新签名:
SPECIAL_CERT_STRINGS = ("PRESIGNED", "EXTERNAL")
3.2 更新system,recovery签名
当sign_target_files_apks
指定了-O
参数时,将会执行如下逻辑:
if OPTIONS.replace_ota_keys:
new_recovery_keys = ReplaceOtaKeys(input_tf_zip, output_tf_zip, misc_info)
if new_recovery_keys:
write_to_temp("RECOVERY/RAMDISK/res/keys", 0o755 << 16, new_recovery_keys)
即当制定了-O
参数时,将会调用ReplaceOtaKeys方法。
- 获取/META/otakeys.txt
当方案中定义了PRODUCT_OTA_PUBLIC_KEYS
时,在编译时会将内容写入otakeys.txt文件中
try:
keylist = input_tf_zip.read("META/otakeys.txt").split()
except KeyError:
raise common.ExternalError("can't read META/otakeys.txt from input")
2.获取recovery的证书
同理,如果在方案中制定了extra_recovery_keys
,也会从misc_info
中找证书
extra_recovry_keys = misc_info.get("extra_recovery_keys", None)
if extra_recovery_keys:
extra_recovery_keys = [OPTIONS.key_map.get(k, k) + ".x509.pem"
for k in extra_recovery_keys.split()]
if extra_recovery_keys:
print "extra recovery-only key(s): " + ", ".join(extra_recovery_keys)
else:
extra_recovery_keys = []
3.对mapped_keys
赋值
mapped_keys = []
for k in keylist:
m = re.match(r"^(.*)\.x509\.pem$", k)
if not m:
raise common.ExternalError(
"can't parse \"%s\" from META/otakeys.txt" % (k,))
k = m.group(1)
mapped_keys.append(OPTIONS.key_map.get(k, k) + ".x509.pem")
if mapped_keys:
print "using:\n ", "\n ".join(mapped_keys)
print "for OTA package verification"
else:
devkey = misc_info.get("default_system_dev_certificate",
"build/target/product/security/testkey")
mapped_keys.append(
OPTIONS.key_map.get(devkey, devkey) + ".x509.pem")
print "META/otakeys.txt has no keys; using", mapped_keys[0]
假如otakey文件中有内容,则将第一个key添加到mapped_keys
中。否则就默认为系统的testkey。
4.利用dumpkey.jar为recovery创建新的key
p = common.Run(["java", "-jar",
os.path.join(OPTIONS.search_path, "framework", "dumpkey.jar")]
+ mapped_keys + extra_recovery_keys,
stdout=subprocess.PIPE)
new_recovery_keys, _ = p.communicate()
if p.returncode != 0:
raise common.ExternalError("failed to run dumpkeys")
common.ZipWriteStr(output_tf_zip, "RECOVERY/RAMDISK/res/keys",
new_recovery_keys)
通过之前的extra_recovery_keys
作为参数,将公钥内容打印出来,dumpkey.jar的内容如下:
5.更新otacerts.zip
最后会将mapped_keys
中的文件写入otacerts.zip中。如果otakey为空,则默认为testkey.x509.pem,如果指定了key的路径(-d
),以及设置了-O
,那么由于testkey通过方法BuildKeyMap绑定了releasekey,因此会替换为releasekey.x509.pem
temp_file = cStringIO.StringIO()
certs_zip = zipfile.ZipFile(temp_file, "w")
for k in mapped_keys:
common.ZipWrite(certs_zip, k)
common.ZipClose(certs_zip)
common.ZipWriteStr(output_tf_zip, "SYSTEM/etc/security/otacerts.zip",
temp_file.getvalue())
4. ota_from_target_files.py
在上述步骤中,通过对targetfiles中的otacerts.zip以及recovery的/res/keys进行更新后,生成出来的固件假如与后续的ota包签名不符,那么在校验的时候也是会失败,所以在生成ota包时,也必须指定相应的公钥。
在ota_from_target_files
中有如下的解析:
-k (--package_key) Key to use to sign the package (default is
the value of default_system_dev_certificate from the input
target-files's META/misc_info.txt, or
"build/target/product/security/testkey" if that value is not
specified).
并且在选项中假如未定义"--no_signing"
,而且-k
未指定,将会使用原生的的testkey。
# Use the default key to sign the package if not specified with package_key.
if not OPTIONS.no_signing:
if OPTIONS.package_key is None:
OPTIONS.package_key = OPTIONS.info_dict.get(
"default_system_dev_certificate",
"build/target/product/security/testkey")
当OTA包完成所有的打包工作后,最终会调用到如下方法,表明要对整包进行签名。
# Sign the whole package to comply with the Android OTA package format.
def SignOutput(temp_zip_name, output_zip_name)
其实现如下:
def SignOutput(temp_zip_name, output_zip_name):
key_passwords = common.GetKeyPasswords([OPTIONS.package_key])
pw = key_passwords[OPTIONS.package_key]
common.SignFile(temp_zip_name, output_zip_name, OPTIONS.package_key, pw,
whole_file=True)
- common.GetKeyPasswords将会提示用户输入密码,假如密码与之前创建公钥私钥的密码不对,则提示错误,匹配才能往下进行,并返回更新后{key:password}
- 根据
ota_from_targetfiles
中指定的package_key
,找到其密码pw - 调用SignFile对OTA包进行整包签名
def SignFile(input_name, output_name, key, password, min_api_level=None,
codename_to_api_level_map=dict(),
whole_file=False):
"""Sign the input_name zip/jar/apk, producing output_name. Use the
given key and password (the latter may be None if the key does not
have a password.
If whole_file is true, use the "-w" option to SignApk to embed a
signature that covers the whole file in the archive comment of the
zip file.
min_api_level is the API Level (int) of the oldest platform this file may end
up on. If not specified for an APK, the API Level is obtained by interpreting
the minSdkVersion attribute of the APK's AndroidManifest.xml.
codename_to_api_level_map is needed to translate the codename which may be
encountered as the APK's minSdkVersion.
"""
java_library_path = os.path.join(
OPTIONS.search_path, OPTIONS.signapk_shared_library_path)
cmd = [OPTIONS.java_path, OPTIONS.java_args,
"-Djava.library.path=" + java_library_path,
"-jar",
os.path.join(OPTIONS.search_path, OPTIONS.signapk_path)]
cmd.extend(OPTIONS.extra_signapk_args)
if whole_file:
cmd.append("-w")
min_sdk_version = min_api_level
if min_sdk_version is None:
if not whole_file:
min_sdk_version = GetMinSdkVersionInt(
input_name, codename_to_api_level_map)
if min_sdk_version is not None:
cmd.extend(["--min-sdk-version", str(min_sdk_version)])
cmd.extend([key + OPTIONS.public_key_suffix,
key + OPTIONS.private_key_suffix,
input_name, output_name])
p = Run(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
if password is not None:
password += "\n"
p.communicate(password)
if p.returncode != 0:
raise ExternalError("signapk.jar failed: return code %s" % (p.returncode,))
这里实际进行的命令是:
java -Xmx2048m -Djava.library.path=$ANDROID_BUILD_TOP/out/host/linux-x86/lib64 -jar $ANDROID_BUILD_TOP/out/host/linux-x86/framework/signapk.jar -w $ANDROID_BUILD_TOP/build/target/product/security/testkey.x509.pem $ANDROID_BUILD_TOP/build/target/product/security/testkey.pk8 $1 $2
通过反编译可以看出对ota包的签名实际操作如下:
- 计算出key的个数,这里由于公钥私钥成对出现,而且指定输入输出文件,那么key的对数即为在
-w
之后的个数除以2,再减去1,ota签名时numKeys为1.
int numKeys = (args.length - argstart) / 2 - 1;
if ((signWholeFile) && (numKeys > 1)) {//这里证明了可以的对数只能为1
System.err.println("Only one key may be used with -w.");
System.exit(2);
}
- 载入输入文件和输出文件参数
String inputFilename = args[(args.length - 2)];
String outputFilename = args[(args.length - 1)];
inputJar = new JarFile(new File(inputFilename), false);
outputFile = new FileOutputStream(outputFilename);
- 读取公钥内容
File firstPublicKeyFile = new File(args[(argstart + 0)]);//获取公钥文件
X509Certificate[] publicKey = new X509Certificate[numKeys];//新建X509证书
try {
for (int i = 0; i < numKeys; i++) {
int argNum = argstart + i * 2;
publicKey[i] = readPublicKey(new File(args[argNum]));//将公钥读取到X509结构的publicKey中
hashes |= getDigestAlgorithm(publicKey[i], minSdkVersion);//计算摘要
}
} catch (IllegalArgumentException e) {
System.err.println(e);
System.exit(1);
}
- 读取私钥内容
timestamp -= TimeZone.getDefault().getOffset(timestamp);
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(inputJar, firstPublicKeyFile, publicKey[0], privateKey[0], timestamp, minSdkVersion, outputFile);
其实现如下:
CMSSigner cmsOut = new CMSSigner(inputJar, publicKeyFile, publicKey, privateKey, timestamp, minSdkVersion, outputStream);
ByteArrayOutputStream temp = new ByteArrayOutputStream();
byte[] message = "signed by SignApk".getBytes("UTF-8");
temp.write(message);//将message写入输出流
temp.write(0);
cmsOut.writeSignatureBlock(temp);
byte[] zipData = cmsOut.getSigner().getTail();
//检查zip格式核心目录结束标识是否为504B0506
if ((zipData[(zipData.length - 22)] != 80) || (zipData[(zipData.length - 21)] != 75) || (zipData[(zipData.length - 20)] != 5) || (zipData[(zipData.length - 19)] != 6))
{
throw new IllegalArgumentException("zip data already has an archive comment");
}
int total_size = temp.size() + 6;
//检查签名大小
if (total_size > 65535) {
throw new IllegalArgumentException("signature is too big for ZIP file comment");
}
//结尾格式以2字节`signature_staret` ff ff 2字节`total_size`结尾
int signature_start = total_size - message.length - 1;
temp.write(signature_start & 0xFF);
temp.write(signature_start >> 8 & 0xFF);
temp.write(255);
temp.write(255);
temp.write(total_size & 0xFF);
temp.write(total_size >> 8 & 0xFF);
temp.flush();
//检查temp流的结尾格式
byte[] b = temp.toByteArray();
for (int i = 0; i < b.length - 3; i++) {
if ((b[i] == 80) && (b[(i + 1)] == 75) && (b[(i + 2)] == 5) && (b[(i + 3)] == 6)) {
throw new IllegalArgumentException("found spurious EOCD header at " + i);
}
}
outputStream.write(total_size & 0xFF);
outputStream.write(total_size >> 8 & 0xFF);
temp.writeTo(outputStream);//将temp流写到输出文件中
5. 校验OTA包
5.1 RecoverySystem校验OTA包
RecoverySystem中有校验OTA包的接口
public static void verifyPackage(File packageFile,
ProgressListener listener,
File deviceCertsZipFile)
其校验流程如下:
1.获取OTA包
final RandomAccessFile raf = new RandomAccessFile(packageFile, "r");
2.校验OTA包的尾部
raf.seek(fileLen - 6);
byte[] footer = new byte[6];
raf.readFully(footer);
//校验后6字节中间的两个字节是否为ff,与signapk.jar逻辑相同
if (footer[2] != (byte)0xff || footer[3] != (byte)0xff) {
throw new SignatureException("no signature in file (no footer)");
}
//获取commentSize以及signatureStart
final int commentSize = (footer[4] & 0xff) | ((footer[5] & 0xff) << 8);
final int signatureStart = (footer[0] & 0xff) | ((footer[1] & 0xff) << 8);
byte[] eocd = new byte[commentSize + 22];
raf.seek(fileLen - (commentSize + 22));
raf.readFully(eocd);
// Check that we have found the start of the
// end-of-central-directory record.
//检查核心标识是否为504b0506
if (eocd[0] != (byte)0x50 || eocd[1] != (byte)0x4b ||
eocd[2] != (byte)0x05 || eocd[3] != (byte)0x06) {
throw new SignatureException("no signature in file (bad footer)");
}
//检查eocd后四个字节是否是EOCD标识,如果是则报错
for (int i = 4; i < eocd.length-3; ++i) {
if (eocd[i ] == (byte)0x50 && eocd[i+1] == (byte)0x4b &&
eocd[i+2] == (byte)0x05 && eocd[i+3] == (byte)0x06) {
throw new SignatureException("EOCD marker found after start of EOCD");
}
}
3.从OT包中获取证书
// Parse the signature
PKCS7 block =
new PKCS7(new ByteArrayInputStream(eocd, commentSize+22-signatureStart, signatureStart));
//获取证书
// Take the first certificate from the signature (packages
// should contain only one).
X509Certificate[] certificates = block.getCertificates();
if (certificates == null || certificates.length == 0) {
throw new SignatureException("signature contains no certificates");
}
X509Certificate cert = certificates[0];
//获取公钥
PublicKey signatureKey = cert.getPublicKey();
SignerInfo[] signerInfos = block.getSignerInfos();
if (signerInfos == null || signerInfos.length == 0) {
throw new SignatureException("signature contains no signedData");
}
SignerInfo signerInfo = signerInfos[0];
4.对比公钥信息
// Check that the public key of the certificate contained
// in the package equals one of our trusted public keys.
boolean verified = false;
HashSet trusted = getTrustedCerts(
deviceCertsZipFile == null ? DEFAULT_KEYSTORE : deviceCertsZipFile);
for (X509Certificate c : trusted) {
if (c.getPublicKey().equals(signatureKey)) {
verified = true;
break;
}
}
if (!verified) {
throw new SignatureException("signature doesn't match any trusted key");
}
确保OTA包中获取的公钥是在信任的keylist之中,信任的keylist是从以下目录获取的:
private static final File DEFAULT_KEYSTORE =
new File("/system/etc/security/otacerts.zip");
这个文件就是在sign_target_files_apks.py
中通过指定-O
选项时更新的。假如没有指定新的签名目录,那么使用原生的testkey作为密钥。所以从校验可以看出,至少对ota整包的签名的公钥信息必须要与待OTA升级的system/etc/otacerts.zip中的公钥信息是要一致的,否则将在校验时出错。
这个校验流程与常规的签名校验一致,假如加密文件,这里OTA包,使用了私钥进行签名,并在尾部附上公钥,那么正常而言,使用该公钥即可对其进行验证,但是这里就多了一个流程是公钥必须要和需要升级的固件是一致的,相当于CA的一个作用证明该公钥是有效的,才会继续使用公钥去计算出OTA包中的摘要,然后通过该摘要值与给到的OTA包进行计算的摘要值进行对比,保证该OTA包是没有经过修改的。
5.对比摘要值
SignerInfo verifyResult = block.verify(signerInfo, new InputStream
这里的veriry中还实现了一个read方法,应当是对ota包中的内容进行读取,并与signerinfo的信息进行比较,其中read的内容范围为:
// The signature covers all of the OTA package except the
// archive comment and its 2-byte length.
long toRead = fileLen - commentSize - 2;
当校验结果为null时出错
if (verifyResult == null) {
throw new SignatureException("signature digest verification failed");
}
5.2 recovery校验OTA包
recovery的校验流程与RecoverySystem中相仿。
6. 结论
- 一般而言,正常编译OTA包与编译固件,均是默认使用testkey进行签名
- 假如指定了新的签名目录,那么必须保证,待升级的固件中的公钥信息(包括system以及recovery)都要与升级的OTA包中的公钥信息等匹配,即使用同一对密钥对。