support APK Signature V2 when apksigner is installed

This was done with much help from @uniqx.  This is the first level of
supporting APK Signatures v1, v2, and v3.  This is enough to include
APKs with any combo of v1/v2/v3 signatures.  For this to work at all,
apksigner and androguard 3.3.3+ must be installed.

closes #399
This commit is contained in:
Hans-Christoph Steiner 2019-01-30 16:48:35 +01:00
parent ea84014f9b
commit d96f5ff660
6 changed files with 98 additions and 33 deletions

View File

@ -2516,28 +2516,45 @@ def signer_fingerprint(cert_encoded):
return hashlib.sha256(cert_encoded).hexdigest()
def get_first_signer_certificate(apkpath):
"""Get the first signing certificate from the APK, DER-encoded"""
certs = None
cert_encoded = None
with zipfile.ZipFile(apkpath, 'r') as apk:
cert_files = [n for n in apk.namelist() if SIGNATURE_BLOCK_FILE_REGEX.match(n)]
if len(cert_files) > 1:
logging.error(_("Found multiple JAR Signature Block Files in {path}").format(path=apkpath))
return None
elif len(cert_files) == 1:
cert_encoded = get_certificate(apk.read(cert_files[0]))
if cert_encoded is None:
apkobject = _get_androguard_APK(apkpath)
certs = apkobject.get_certificates_der_v2()
if len(certs) > 0:
logging.info(_('Using APK v2 Signature'))
cert_encoded = certs[0]
if not cert_encoded:
logging.error(_("No signing certificates found in {path}").format(path=apkpath))
return None
return cert_encoded
def apk_signer_fingerprint(apk_path):
"""Obtain sha256 signing-key fingerprint for APK.
Extracts hexadecimal sha256 signing-key fingerprint string
for a given APK.
:param apkpath: path to APK
:param apk_path: path to APK
:returns: signature fingerprint
"""
with zipfile.ZipFile(apk_path, 'r') as apk:
certs = [n for n in apk.namelist() if SIGNATURE_BLOCK_FILE_REGEX.match(n)]
if len(certs) < 1:
logging.error("Found no signing certificates on %s" % apk_path)
return None
if len(certs) > 1:
logging.error("Found multiple signing certificates on %s" % apk_path)
return None
cert_encoded = get_certificate(apk.read(certs[0]))
return signer_fingerprint(cert_encoded)
cert_encoded = get_first_signer_certificate(apk_path)
if not cert_encoded:
return None
return signer_fingerprint(cert_encoded)
def apk_signer_fingerprint_short(apk_path):

View File

@ -414,29 +414,26 @@ def resize_all_icons(repodirs):
def getsig(apkpath):
""" Get the signing certificate of an apk. To get the same md5 has that
Android gets, we encode the .RSA certificate in a specific format and pass
it hex-encoded to the md5 digest algorithm.
"""Get the unique ID for the signing certificate of an APK.
This uses a strange algorithm that was devised at the very
beginning of F-Droid. Since it is only used for checking
signature compatibility, it does not matter much that it uses MD5.
To get the same MD5 has that fdroidclient gets, we encode the .RSA
certificate in a specific format and pass it hex-encoded to the
md5 digest algorithm. This is not the same as the standard X.509
certificate fingerprint.
:param apkpath: path to the apk
:returns: A string containing the md5 of the signature of the apk or None
if an error occurred.
"""
with zipfile.ZipFile(apkpath, 'r') as apk:
certs = [n for n in apk.namelist() if common.SIGNATURE_BLOCK_FILE_REGEX.match(n)]
if len(certs) < 1:
logging.error(_("No signing certificates found in {path}").format(path=apkpath))
return None
if len(certs) > 1:
logging.error(_("Found multiple signing certificates in {path}").format(path=apkpath))
return None
cert = apk.read(certs[0])
cert_encoded = common.get_certificate(cert)
cert_encoded = common.get_first_signer_certificate(apkpath)
if not cert_encoded:
return None
return hashlib.md5(hexlify(cert_encoded)).hexdigest() # nosec just used as ID for signing key

View File

@ -161,6 +161,7 @@ class CommonTest(unittest.TestCase):
testfiles = []
testfiles.append(os.path.join(self.basedir, 'urzip-release.apk'))
testfiles.append(os.path.join(self.basedir, 'urzip-release-unsigned.apk'))
testfiles.append(os.path.join(self.basedir, 'v2.only.sig_2.apk'))
for apkfile in testfiles:
debuggable = fdroidserver.common.is_apk_and_debuggable(apkfile)
self.assertFalse(debuggable,

Binary file not shown.

View File

@ -14,6 +14,7 @@ import sys
import tempfile
import unittest
import yaml
import zipfile
from binascii import unhexlify
from distutils.version import LooseVersion
@ -233,6 +234,45 @@ class UpdateTest(unittest.TestCase):
self.assertEqual(sig, 'e0ecb5fc2d63088e4a07ae410a127722',
"python sig should be: " + str(sig))
def test_getsig(self):
# config needed to use jarsigner and keytool
config = dict()
fdroidserver.common.fill_config_defaults(config)
fdroidserver.update.config = config
sig = fdroidserver.update.getsig('urzip-release-unsigned.apk')
self.assertIsNone(sig)
good_fingerprint = 'b4964fd759edaa54e65bb476d0276880'
apkpath = 'urzip-release.apk' # v1 only
sig = fdroidserver.update.getsig(apkpath)
self.assertEqual(good_fingerprint, sig,
'python sig was: ' + str(sig))
apkpath = 'repo/v1.v2.sig_1020.apk'
sig = fdroidserver.update.getsig(apkpath)
self.assertEqual(good_fingerprint, sig,
'python sig was: ' + str(sig))
# check that v1 and v2 have the same certificate
import hashlib
from binascii import hexlify
from androguard.core.bytecodes.apk import APK
apkobject = APK(apkpath)
cert_encoded = apkobject.get_certificates_der_v2()[0]
self.assertEqual(good_fingerprint, sig,
hashlib.md5(hexlify(cert_encoded)).hexdigest()) # nosec just used as ID for signing key
filename = 'v2.only.sig_2.apk'
with zipfile.ZipFile(filename) as z:
self.assertTrue('META-INF/MANIFEST.MF' in z.namelist(), 'META-INF/MANIFEST.MF required')
for f in z.namelist():
# ensure there are no v1 signature files
self.assertIsNone(fdroidserver.common.SIGNATURE_BLOCK_FILE_REGEX.match(f))
sig = fdroidserver.update.getsig(filename)
self.assertEqual(good_fingerprint, sig,
"python sig was: " + str(sig))
def testScanApksAndObbs(self):
os.chdir(os.path.join(localmodule, 'tests'))
if os.path.basename(os.getcwd()) != 'tests':
@ -254,7 +294,7 @@ class UpdateTest(unittest.TestCase):
apps = fdroidserver.metadata.read_metadata(xref=True)
knownapks = fdroidserver.common.KnownApks()
apks, cachechanged = fdroidserver.update.process_apks({}, 'repo', knownapks, False)
self.assertEqual(len(apks), 16)
self.assertEqual(len(apks), 17)
apk = apks[1]
self.assertEqual(apk['packageName'], 'com.politedroid')
self.assertEqual(apk['versionCode'], 3)
@ -321,7 +361,7 @@ class UpdateTest(unittest.TestCase):
fdroidserver.update.options.clean = False
read_from_json = fdroidserver.update.get_cache()
self.assertEqual(18, len(read_from_json))
self.assertEqual(19, len(read_from_json))
for f in glob.glob('repo/*.apk'):
self.assertTrue(os.path.basename(f) in read_from_json)
@ -363,6 +403,16 @@ class UpdateTest(unittest.TestCase):
else:
continue
apk_info = fdroidserver.update.scan_apk('repo/v1.v2.sig_1020.apk')
self.assertIsNone(apk_info.get('maxSdkVersion'))
self.assertEqual(apk_info.get('versionName'), 'v1+2')
self.assertEqual(apk_info.get('versionCode'), 1020)
apk_info = fdroidserver.update.scan_apk('v2.only.sig_2.apk')
self.assertIsNone(apk_info.get('maxSdkVersion'))
self.assertEqual(apk_info.get('versionName'), 'v2-only')
self.assertEqual(apk_info.get('versionCode'), 2)
apk_info = fdroidserver.update.scan_apk('repo/souch.smsbypass_9.apk')
self.assertIsNone(apk_info.get('maxSdkVersion'))
self.assertEqual(apk_info.get('versionName'), '0.9')
@ -623,7 +673,7 @@ class UpdateTest(unittest.TestCase):
knownapks = fdroidserver.common.KnownApks()
apks, cachechanged = fdroidserver.update.process_apks({}, 'repo', knownapks, False)
fdroidserver.update.translate_per_build_anti_features(apps, apks)
self.assertEqual(len(apks), 16)
self.assertEqual(len(apks), 17)
foundtest = False
for apk in apks:
if apk['packageName'] == 'com.politedroid' and apk['versionCode'] == 3:

BIN
tests/v2.only.sig_2.apk Normal file

Binary file not shown.