diff --git a/completion/bash-completion b/completion/bash-completion index 2222d954..531f9131 100644 --- a/completion/bash-completion +++ b/completion/bash-completion @@ -90,8 +90,7 @@ __complete_options() { __complete_build() { opts="-v -q -l -s -t -f -a -w" - lopts="--verbose --quiet --latest --stop --test --server --reset-server - --on-server --skip-scan --no-tarball --force --all --wiki --no-refresh" + lopts="--verbose --quiet --latest --stop --test --server --reset-server --skip-scan --no-tarball --force --all --wiki --no-refresh" case "${cur}" in -*) __complete_options diff --git a/fdroidserver/common.py b/fdroidserver/common.py index 137563d7..16dd101a 100644 --- a/fdroidserver/common.py +++ b/fdroidserver/common.py @@ -72,7 +72,7 @@ MINIMUM_AAPT_VERSION = '26.0.0' VERCODE_OPERATION_RE = re.compile(r'^([ 0-9/*+-]|%c)+$') # 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_ID_TRIPLET_REGEX = re.compile(r"^package: name='(\w[^']*)' versionCode='([^']+)' versionName='([^']*)'") 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)') -def signer_fingerprint_short(sig): - """Obtain shortened sha256 signing-key fingerprint for pkcs7 signature. +def signer_fingerprint_short(cert_encoded): + """Obtain shortened sha256 signing-key fingerprint for pkcs7 DER certficate. Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint 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. """ - return signer_fingerprint(sig)[:7] + return signer_fingerprint(cert_encoded)[:7] -def signer_fingerprint(sig): - """Obtain sha256 signing-key fingerprint for pkcs7 signature. +def signer_fingerprint(cert_encoded): + """Obtain sha256 signing-key fingerprint for pkcs7 DER certificate. Extracts hexadecimal sha256 signing-key fingerprint string for a given pkcs7 signature. @@ -2513,32 +2513,53 @@ def signer_fingerprint(sig): :param: Contents of an APK signature. :returns: shortened signature fingerprint. """ - cert_encoded = get_certificate(sig) 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): """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 CERT_PATH_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 = apk.read(certs[0]) - return signer_fingerprint(cert) + 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): @@ -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)) for sig in sigs: with open(sig, 'rb') as f: - return signer_fingerprint(f.read()) + return signer_fingerprint(get_certificate(f.read())) return None @@ -3071,13 +3092,17 @@ def get_cert_fingerprint(pubkey): 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. - :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] + content = decoder.decode(signature_block_file, asn1Spec=rfc2315.ContentInfo())[0] if content.getComponentByName('contentType') != rfc2315.signedData: return None content = decoder.decode(content.getComponentByName('content'), diff --git a/fdroidserver/index.py b/fdroidserver/index.py index 7585563f..d8477dc5 100644 --- a/fdroidserver/index.py +++ b/fdroidserver/index.py @@ -751,7 +751,7 @@ def get_public_key_from_jar(jar): :return: the public key from the jar and its fingerprint """ # 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: raise VerificationException(_("Found no signing certificates for repository.")) if len(certs) > 1: diff --git a/fdroidserver/publish.py b/fdroidserver/publish.py index 6561a9b8..ac9a3f44 100644 --- a/fdroidserver/publish.py +++ b/fdroidserver/publish.py @@ -280,7 +280,7 @@ def main(): signaturefile, signedfile, manifest = signingfiles 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) devsignedtmp = os.path.join(tmp_dir, devsigned) shutil.copy(apkfile, devsignedtmp) diff --git a/fdroidserver/update.py b/fdroidserver/update.py index 8ae0c330..a0f9a5f2 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -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.CERT_PATH_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 diff --git a/tests/common.TestCase b/tests/common.TestCase index 6c5ba647..b976376f 100755 --- a/tests/common.TestCase +++ b/tests/common.TestCase @@ -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, diff --git a/tests/repo/v1.v2.sig_1020.apk b/tests/repo/v1.v2.sig_1020.apk new file mode 100644 index 00000000..006ff764 Binary files /dev/null and b/tests/repo/v1.v2.sig_1020.apk differ diff --git a/tests/update.TestCase b/tests/update.TestCase index 80870e02..2ba207b8 100755 --- a/tests/update.TestCase +++ b/tests/update.TestCase @@ -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: diff --git a/tests/v2.only.sig_2.apk b/tests/v2.only.sig_2.apk new file mode 100644 index 00000000..0b1804d3 Binary files /dev/null and b/tests/v2.only.sig_2.apk differ