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() 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): def apk_signer_fingerprint(apk_path):
"""Obtain sha256 signing-key fingerprint for APK. """Obtain sha256 signing-key fingerprint for APK.
Extracts hexadecimal sha256 signing-key fingerprint string Extracts hexadecimal sha256 signing-key fingerprint string
for a given APK. for a given APK.
:param apkpath: path to APK :param apk_path: path to APK
:returns: signature fingerprint :returns: signature fingerprint
""" """
with zipfile.ZipFile(apk_path, 'r') as apk: cert_encoded = get_first_signer_certificate(apk_path)
certs = [n for n in apk.namelist() if SIGNATURE_BLOCK_FILE_REGEX.match(n)] if not cert_encoded:
return None
if len(certs) < 1: return signer_fingerprint(cert_encoded)
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)
def apk_signer_fingerprint_short(apk_path): def apk_signer_fingerprint_short(apk_path):

View File

@ -414,29 +414,26 @@ def resize_all_icons(repodirs):
def getsig(apkpath): def getsig(apkpath):
""" Get the signing certificate of an apk. To get the same md5 has that """Get the unique ID for the signing certificate of an APK.
Android gets, we encode the .RSA certificate in a specific format and pass
it hex-encoded to the md5 digest algorithm. 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 :param apkpath: path to the apk
:returns: A string containing the md5 of the signature of the apk or None :returns: A string containing the md5 of the signature of the apk or None
if an error occurred. if an error occurred.
""" """
with zipfile.ZipFile(apkpath, 'r') as apk: cert_encoded = common.get_first_signer_certificate(apkpath)
certs = [n for n in apk.namelist() if common.SIGNATURE_BLOCK_FILE_REGEX.match(n)] if not cert_encoded:
return None
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)
return hashlib.md5(hexlify(cert_encoded)).hexdigest() # nosec just used as ID for signing key 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 = []
testfiles.append(os.path.join(self.basedir, 'urzip-release.apk')) 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, 'urzip-release-unsigned.apk'))
testfiles.append(os.path.join(self.basedir, 'v2.only.sig_2.apk'))
for apkfile in testfiles: for apkfile in testfiles:
debuggable = fdroidserver.common.is_apk_and_debuggable(apkfile) debuggable = fdroidserver.common.is_apk_and_debuggable(apkfile)
self.assertFalse(debuggable, self.assertFalse(debuggable,

Binary file not shown.

View File

@ -14,6 +14,7 @@ import sys
import tempfile import tempfile
import unittest import unittest
import yaml import yaml
import zipfile
from binascii import unhexlify from binascii import unhexlify
from distutils.version import LooseVersion from distutils.version import LooseVersion
@ -233,6 +234,45 @@ class UpdateTest(unittest.TestCase):
self.assertEqual(sig, 'e0ecb5fc2d63088e4a07ae410a127722', self.assertEqual(sig, 'e0ecb5fc2d63088e4a07ae410a127722',
"python sig should be: " + str(sig)) "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): def testScanApksAndObbs(self):
os.chdir(os.path.join(localmodule, 'tests')) os.chdir(os.path.join(localmodule, 'tests'))
if os.path.basename(os.getcwd()) != 'tests': if os.path.basename(os.getcwd()) != 'tests':
@ -254,7 +294,7 @@ class UpdateTest(unittest.TestCase):
apps = fdroidserver.metadata.read_metadata(xref=True) apps = fdroidserver.metadata.read_metadata(xref=True)
knownapks = fdroidserver.common.KnownApks() knownapks = fdroidserver.common.KnownApks()
apks, cachechanged = fdroidserver.update.process_apks({}, 'repo', knownapks, False) apks, cachechanged = fdroidserver.update.process_apks({}, 'repo', knownapks, False)
self.assertEqual(len(apks), 16) self.assertEqual(len(apks), 17)
apk = apks[1] apk = apks[1]
self.assertEqual(apk['packageName'], 'com.politedroid') self.assertEqual(apk['packageName'], 'com.politedroid')
self.assertEqual(apk['versionCode'], 3) self.assertEqual(apk['versionCode'], 3)
@ -321,7 +361,7 @@ class UpdateTest(unittest.TestCase):
fdroidserver.update.options.clean = False fdroidserver.update.options.clean = False
read_from_json = fdroidserver.update.get_cache() 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'): for f in glob.glob('repo/*.apk'):
self.assertTrue(os.path.basename(f) in read_from_json) self.assertTrue(os.path.basename(f) in read_from_json)
@ -363,6 +403,16 @@ class UpdateTest(unittest.TestCase):
else: else:
continue 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') apk_info = fdroidserver.update.scan_apk('repo/souch.smsbypass_9.apk')
self.assertIsNone(apk_info.get('maxSdkVersion')) self.assertIsNone(apk_info.get('maxSdkVersion'))
self.assertEqual(apk_info.get('versionName'), '0.9') self.assertEqual(apk_info.get('versionName'), '0.9')
@ -623,7 +673,7 @@ class UpdateTest(unittest.TestCase):
knownapks = fdroidserver.common.KnownApks() knownapks = fdroidserver.common.KnownApks()
apks, cachechanged = fdroidserver.update.process_apks({}, 'repo', knownapks, False) apks, cachechanged = fdroidserver.update.process_apks({}, 'repo', knownapks, False)
fdroidserver.update.translate_per_build_anti_features(apps, apks) fdroidserver.update.translate_per_build_anti_features(apps, apks)
self.assertEqual(len(apks), 16) self.assertEqual(len(apks), 17)
foundtest = False foundtest = False
for apk in apks: for apk in apks:
if apk['packageName'] == 'com.politedroid' and apk['versionCode'] == 3: if apk['packageName'] == 'com.politedroid' and apk['versionCode'] == 3:

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

Binary file not shown.