Merge branch 'v2-sig-support' into 'master'

first basic support for APK Signature v2 and v3

See merge request fdroid/fdroidserver!618
This commit is contained in:
Hans-Christoph Steiner 2019-02-01 08:40:20 +00:00
commit 64bab7a94c
9 changed files with 124 additions and 52 deletions

View file

@ -90,8 +90,7 @@ __complete_options() {
__complete_build() { __complete_build() {
opts="-v -q -l -s -t -f -a -w" opts="-v -q -l -s -t -f -a -w"
lopts="--verbose --quiet --latest --stop --test --server --reset-server lopts="--verbose --quiet --latest --stop --test --server --reset-server --skip-scan --no-tarball --force --all --wiki --no-refresh"
--on-server --skip-scan --no-tarball --force --all --wiki --no-refresh"
case "${cur}" in case "${cur}" in
-*) -*)
__complete_options __complete_options

View file

@ -72,7 +72,7 @@ MINIMUM_AAPT_VERSION = '26.0.0'
VERCODE_OPERATION_RE = re.compile(r'^([ 0-9/*+-]|%c)+$') VERCODE_OPERATION_RE = re.compile(r'^([ 0-9/*+-]|%c)+$')
# A signature block file with a .DSA, .RSA, or .EC extension # A signature block file with a .DSA, .RSA, or .EC extension
CERT_PATH_REGEX = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$') SIGNATURE_BLOCK_FILE_REGEX = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
APK_NAME_REGEX = re.compile(r'^([a-zA-Z][\w.]*)_(-?[0-9]+)_?([0-9a-f]{7})?\.apk') APK_NAME_REGEX = re.compile(r'^([a-zA-Z][\w.]*)_(-?[0-9]+)_?([0-9a-f]{7})?\.apk')
APK_ID_TRIPLET_REGEX = re.compile(r"^package: name='(\w[^']*)' versionCode='([^']+)' versionName='([^']*)'") APK_ID_TRIPLET_REGEX = re.compile(r"^package: name='(\w[^']*)' versionCode='([^']+)' versionName='([^']*)'")
STANDARD_FILE_NAME_REGEX = re.compile(r'^(\w[\w.]*)_(-?[0-9]+)\.\w+') STANDARD_FILE_NAME_REGEX = re.compile(r'^(\w[\w.]*)_(-?[0-9]+)\.\w+')
@ -2492,20 +2492,20 @@ def place_srclib(root_dir, number, libpath):
APK_SIGNATURE_FILES = re.compile(r'META-INF/[0-9A-Za-z_\-]+\.(SF|RSA|DSA|EC)') APK_SIGNATURE_FILES = re.compile(r'META-INF/[0-9A-Za-z_\-]+\.(SF|RSA|DSA|EC)')
def signer_fingerprint_short(sig): def signer_fingerprint_short(cert_encoded):
"""Obtain shortened sha256 signing-key fingerprint for pkcs7 signature. """Obtain shortened sha256 signing-key fingerprint for pkcs7 DER certficate.
Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint
for a given pkcs7 signature. for a given pkcs7 signature.
:param sig: Contents of an APK signing certificate. :param cert_encoded: Contents of an APK signing certificate.
:returns: shortened signing-key fingerprint. :returns: shortened signing-key fingerprint.
""" """
return signer_fingerprint(sig)[:7] return signer_fingerprint(cert_encoded)[:7]
def signer_fingerprint(sig): def signer_fingerprint(cert_encoded):
"""Obtain sha256 signing-key fingerprint for pkcs7 signature. """Obtain sha256 signing-key fingerprint for pkcs7 DER certificate.
Extracts hexadecimal sha256 signing-key fingerprint string Extracts hexadecimal sha256 signing-key fingerprint string
for a given pkcs7 signature. for a given pkcs7 signature.
@ -2513,32 +2513,53 @@ def signer_fingerprint(sig):
:param: Contents of an APK signature. :param: Contents of an APK signature.
:returns: shortened signature fingerprint. :returns: shortened signature fingerprint.
""" """
cert_encoded = get_certificate(sig)
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 not cert_encoded:
apkobject = _get_androguard_APK(apkpath)
certs = apkobject.get_certificates_der_v2()
if len(certs) > 0:
logging.info(_('Using APK Signature v2'))
cert_encoded = certs[0]
if not cert_encoded:
certs = apkobject.get_certificates_der_v3()
if len(certs) > 0:
logging.info(_('Using APK Signature v3'))
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 CERT_PATH_REGEX.match(n)] if not cert_encoded:
if len(certs) < 1:
logging.error("Found no signing certificates on %s" % apk_path)
return None return None
if len(certs) > 1: return signer_fingerprint(cert_encoded)
logging.error("Found multiple signing certificates on %s" % apk_path)
return None
cert = apk.read(certs[0])
return signer_fingerprint(cert)
def apk_signer_fingerprint_short(apk_path): def apk_signer_fingerprint_short(apk_path):
@ -2591,7 +2612,7 @@ def metadata_find_developer_signature(appid, vercode=None):
raise FDroidException('ambiguous signatures, please make sure there is only one signature in \'{}\'. (The signature has to be the App maintainers signature for version of the APK.)'.format(sigdir)) raise FDroidException('ambiguous signatures, please make sure there is only one signature in \'{}\'. (The signature has to be the App maintainers signature for version of the APK.)'.format(sigdir))
for sig in sigs: for sig in sigs:
with open(sig, 'rb') as f: with open(sig, 'rb') as f:
return signer_fingerprint(f.read()) return signer_fingerprint(get_certificate(f.read()))
return None return None
@ -3071,13 +3092,17 @@ def get_cert_fingerprint(pubkey):
return " ".join(ret) return " ".join(ret)
def get_certificate(certificate_file): def get_certificate(signature_block_file):
"""Extracts a DER certificate from JAR Signature's "Signature Block File".
:param signature_block_file: file bytes (as string) representing the
certificate, as read directly out of the APK/ZIP
:return: A binary representation of the certificate's public key,
or None in case of error
""" """
Extracts a certificate from the given file. content = decoder.decode(signature_block_file, asn1Spec=rfc2315.ContentInfo())[0]
:param certificate_file: file bytes (as string) representing the certificate
:return: A binary representation of the certificate's public key, or None in case of error
"""
content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
if content.getComponentByName('contentType') != rfc2315.signedData: if content.getComponentByName('contentType') != rfc2315.signedData:
return None return None
content = decoder.decode(content.getComponentByName('content'), content = decoder.decode(content.getComponentByName('content'),

View file

@ -751,7 +751,7 @@ def get_public_key_from_jar(jar):
:return: the public key from the jar and its fingerprint :return: the public key from the jar and its fingerprint
""" """
# extract certificate from jar # extract certificate from jar
certs = [n for n in jar.namelist() if common.CERT_PATH_REGEX.match(n)] certs = [n for n in jar.namelist() if common.SIGNATURE_BLOCK_FILE_REGEX.match(n)]
if len(certs) < 1: if len(certs) < 1:
raise VerificationException(_("Found no signing certificates for repository.")) raise VerificationException(_("Found no signing certificates for repository."))
if len(certs) > 1: if len(certs) > 1:

View file

@ -280,7 +280,7 @@ def main():
signaturefile, signedfile, manifest = signingfiles signaturefile, signedfile, manifest = signingfiles
with open(signaturefile, 'rb') as f: with open(signaturefile, 'rb') as f:
devfp = common.signer_fingerprint_short(f.read()) devfp = common.signer_fingerprint_short(common.get_signature(f.read()))
devsigned = '{}_{}_{}.apk'.format(appid, vercode, devfp) devsigned = '{}_{}_{}.apk'.format(appid, vercode, devfp)
devsignedtmp = os.path.join(tmp_dir, devsigned) devsignedtmp = os.path.join(tmp_dir, devsigned)
shutil.copy(apkfile, devsignedtmp) shutil.copy(apkfile, devsignedtmp)

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.CERT_PATH_REGEX.match(n)] if not cert_encoded:
if len(certs) < 1:
logging.error(_("No signing certificates found in {path}").format(path=apkpath))
return None 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.