From a23da47118e3d8395bf29a84735a24c7d2f4be0b Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 29 Mar 2017 18:33:09 -0300 Subject: [PATCH 1/2] Add method for downloading (and verifying) a repository index This includes some test cases to test the new code. --- fdroidserver/common.py | 12 +++- fdroidserver/index.py | 106 +++++++++++++++++++++++++++++++++++ fdroidserver/update.py | 6 +- tests/index.TestCase | 80 ++++++++++++++++++++++++++ tests/signindex/unsigned.jar | Bin 0 -> 20068 bytes 5 files changed, 197 insertions(+), 7 deletions(-) create mode 100755 tests/index.TestCase create mode 100644 tests/signindex/unsigned.jar diff --git a/fdroidserver/common.py b/fdroidserver/common.py index a9778943..85bd48e1 100644 --- a/fdroidserver/common.py +++ b/fdroidserver/common.py @@ -46,6 +46,9 @@ import fdroidserver.metadata from .asynchronousfilereader import AsynchronousFileReader +# A signature block file with a .DSA, .RSA, or .EC extension +CERT_PATH_REGEX = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$') + XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android') config = None @@ -2027,16 +2030,21 @@ def verify_apks(signed_apk, unsigned_apk, tmp_dir): return None -def verify_apk_signature(apk): +def verify_apk_signature(apk, jar=False): """verify the signature on an APK Try to use apksigner whenever possible since jarsigner is very shitty: unsigned APKs pass as "verified"! So this has to turn on -strict then check for result 4. + You can set :param: jar to True if you want to use this method + to verify jar signatures. """ if set_command_in_config('apksigner'): - return subprocess.call([config['apksigner'], 'verify', apk]) == 0 + args = [config['apksigner'], 'verify'] + if jar: + args += ['--min-sdk-version=1'] + return subprocess.call(args + [apk]) == 0 else: logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner") return subprocess.call([config['jarsigner'], '-strict', '-verify', apk]) == 4 diff --git a/fdroidserver/index.py b/fdroidserver/index.py index d725aa3a..4cf9d4b7 100644 --- a/fdroidserver/index.py +++ b/fdroidserver/index.py @@ -28,11 +28,17 @@ import os import re import shutil import sys +import tempfile import urllib.parse +import zipfile from binascii import hexlify, unhexlify from datetime import datetime from xml.dom.minidom import Document +import requests +from pyasn1.codec.der import decoder, encoder +from pyasn1_modules import rfc2315 + from fdroidserver import metadata, signindex, common from fdroidserver.common import FDroidPopen, FDroidPopenBytes from fdroidserver.metadata import MetaDataException @@ -535,3 +541,103 @@ def get_raw_mirror(url): url = "/".join(url) return url + + +class VerificationException(Exception): + pass + + +def download_repo_index(url_str, verify_fingerprint=True): + """ + Downloads the repository index from the given :param url_str + and verifies the repository's fingerprint if :param verify_fingerprint is not False. + + :raises: VerificationException() if the repository could not be verified + + :return: The index in JSON format. + """ + url = urllib.parse.urlsplit(url_str) + + fingerprint = None + if verify_fingerprint: + query = urllib.parse.parse_qs(url.query) + if 'fingerprint' not in query: + raise VerificationException("No fingerprint in URL.") + fingerprint = query['fingerprint'][0] + + url = urllib.parse.SplitResult(url.scheme, url.netloc, url.path + '/index-v1.jar', '', '') + r = requests.get(url.geturl()) + + with tempfile.NamedTemporaryFile() as fp: + # write and open JAR file + fp.write(r.content) + jar = zipfile.ZipFile(fp) + + # verify that the JAR signature is valid + verify_jar_signature(fp.name) + + # get public key and its fingerprint from JAR + public_key, public_key_fingerprint = get_public_key_from_jar(jar) + + # compare the fingerprint if verify_fingerprint is True + if verify_fingerprint and fingerprint.upper() != public_key_fingerprint: + raise VerificationException("The repository's fingerprint does not match.") + + # load repository index from JSON + index = json.loads(jar.read('index-v1.json').decode("utf-8")) + index["repo"]["pubkey"] = hexlify(public_key).decode("utf-8") + index["repo"]["fingerprint"] = public_key_fingerprint + + # turn the apps into App objects + index["apps"] = [metadata.App(app) for app in index["apps"]] + + return index + + +def verify_jar_signature(file): + """ + Verifies the signature of a given JAR file. + + :raises: VerificationException() if the JAR's signature could not be verified + """ + if not common.verify_apk_signature(file, jar=True): + raise VerificationException("The repository's index could not be verified.") + + +def get_public_key_from_jar(jar): + """ + Get the public key and its fingerprint from a JAR file. + + :raises: VerificationException() if the JAR was not signed exactly once + + :param jar: a zipfile.ZipFile object + :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)] + if len(certs) < 1: + raise VerificationException("Found no signing certificates for repository.") + if len(certs) > 1: + raise VerificationException("Found multiple signing certificates for repository.") + + # extract public key from certificate + public_key = get_public_key_from_certificate(jar.read(certs[0])) + public_key_fingerprint = common.get_cert_fingerprint(public_key).replace(' ', '') + + return public_key, public_key_fingerprint + + +def get_public_key_from_certificate(certificate_file): + """ + Extracts a public key from the given certificate. + :param certificate_file: file bytes (as string) representing the certificate + :return: A binary representation of the certificate's public key + """ + content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0] + if content.getComponentByName('contentType') != rfc2315.signedData: + raise VerificationException("Unexpected certificate format.") + content = decoder.decode(content.getComponentByName('content'), + asn1Spec=rfc2315.SignedData())[0] + certificates = content.getComponentByName('certificates') + cert = certificates[0].getComponentByName('certificate') + return encoder.encode(cert) diff --git a/fdroidserver/update.py b/fdroidserver/update.py index ee8accc2..94f181dd 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -379,10 +379,6 @@ def resize_all_icons(repodirs): resize_icon(iconpath, density) -# A signature block file with a .DSA, .RSA, or .EC extension -cert_path_regex = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$') - - 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 @@ -404,7 +400,7 @@ def getsig(apkpath): with zipfile.ZipFile(apkpath, 'r') as apk: - certs = [n for n in apk.namelist() if cert_path_regex.match(n)] + certs = [n for n in apk.namelist() if common.CERT_PATH_REGEX.match(n)] if len(certs) < 1: logging.error("Found no signing certificates on %s" % apkpath) diff --git a/tests/index.TestCase b/tests/index.TestCase new file mode 100755 index 00000000..bbfac7bf --- /dev/null +++ b/tests/index.TestCase @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +import optparse +import os +import unittest +import zipfile + +import fdroidserver.common +import fdroidserver.index +import fdroidserver.signindex + + +class IndexTest(unittest.TestCase): + + def setUp(self): + fdroidserver.common.config = None + config = fdroidserver.common.read_config(fdroidserver.common.options) + config['jarsigner'] = fdroidserver.common.find_sdk_tools_cmd('jarsigner') + fdroidserver.common.config = config + fdroidserver.signindex.config = config + + @staticmethod + def test_verify_jar_signature_succeeds(): + basedir = os.path.dirname(__file__) + source_dir = os.path.join(basedir, 'signindex') + for f in ('testy.jar', 'guardianproject.jar'): + testfile = os.path.join(source_dir, f) + fdroidserver.index.verify_jar_signature(testfile) + + def test_verify_jar_signature_fails(self): + basedir = os.path.dirname(__file__) + source_dir = os.path.join(basedir, 'signindex') + testfile = os.path.join(source_dir, 'unsigned.jar') + with self.assertRaises(fdroidserver.index.VerificationException): + fdroidserver.index.verify_jar_signature(testfile) + + def test_get_public_key_from_jar_succeeds(self): + basedir = os.path.dirname(__file__) + source_dir = os.path.join(basedir, 'signindex') + for f in ('testy.jar', 'guardianproject.jar'): + testfile = os.path.join(source_dir, f) + jar = zipfile.ZipFile(testfile) + _, fingerprint = fdroidserver.index.get_public_key_from_jar(jar) + # comparing fingerprints should be sufficient + if f == 'testy.jar': + self.assertTrue(fingerprint == + '818E469465F96B704E27BE2FEE4C63AB' + + '9F83DDF30E7A34C7371A4728D83B0BC1') + if f == 'guardianproject.jar': + self.assertTrue(fingerprint == + 'B7C2EEFD8DAC7806AF67DFCD92EB1812' + + '6BC08312A7F2D6F3862E46013C7A6135') + + def test_get_public_key_from_jar_fails(self): + basedir = os.path.dirname(__file__) + source_dir = os.path.join(basedir, 'signindex') + testfile = os.path.join(source_dir, 'unsigned.jar') + jar = zipfile.ZipFile(testfile) + with self.assertRaises(fdroidserver.index.VerificationException): + fdroidserver.index.get_public_key_from_jar(jar) + + def test_download_repo_index_no_fingerprint(self): + with self.assertRaises(fdroidserver.index.VerificationException): + fdroidserver.index.download_repo_index("http://example.org") + + def test_download_repo_index_no_jar(self): + with self.assertRaises(zipfile.BadZipFile): + fdroidserver.index.download_repo_index("http://example.org?fingerprint=nope") + + # TODO test_download_repo_index with an actual repository + + +if __name__ == "__main__": + parser = optparse.OptionParser() + parser.add_option("-v", "--verbose", action="store_true", default=False, + help="Spew out even more information than normal") + (fdroidserver.common.options, args) = parser.parse_args(['--verbose']) + + newSuite = unittest.TestSuite() + newSuite.addTest(unittest.makeSuite(IndexTest)) + unittest.main() diff --git a/tests/signindex/unsigned.jar b/tests/signindex/unsigned.jar new file mode 100644 index 0000000000000000000000000000000000000000..b62c930a5bb2a8968133ff9896907d0b10b436f2 GIT binary patch literal 20068 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 p`VIBJ=->9RKhZz!VgI1>r|91 Date: Mon, 3 Apr 2017 09:23:06 -0300 Subject: [PATCH 2/2] Reduce code duplication by re-using methods for extracting and verifying certificate --- fdroidserver/common.py | 24 ++++++++++++++++++++++++ fdroidserver/index.py | 20 +------------------- fdroidserver/update.py | 28 +++------------------------- 3 files changed, 28 insertions(+), 44 deletions(-) diff --git a/fdroidserver/common.py b/fdroidserver/common.py index 85bd48e1..42918ed7 100644 --- a/fdroidserver/common.py +++ b/fdroidserver/common.py @@ -42,6 +42,10 @@ from distutils.version import LooseVersion from queue import Queue from zipfile import ZipFile +from pyasn1.codec.der import decoder, encoder +from pyasn1_modules import rfc2315 +from pyasn1.error import PyAsn1Error + import fdroidserver.metadata from .asynchronousfilereader import AsynchronousFileReader @@ -2221,6 +2225,26 @@ def get_cert_fingerprint(pubkey): return " ".join(ret) +def get_certificate(certificate_file): + """ + 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] + if content.getComponentByName('contentType') != rfc2315.signedData: + return None + content = decoder.decode(content.getComponentByName('content'), + asn1Spec=rfc2315.SignedData())[0] + try: + certificates = content.getComponentByName('certificates') + cert = certificates[0].getComponentByName('certificate') + except PyAsn1Error: + logging.error("Certificates not found.") + return None + return encoder.encode(cert) + + def write_to_config(thisconfig, key, value=None, config_file=None): '''write a key/value to the local config.py diff --git a/fdroidserver/index.py b/fdroidserver/index.py index 4cf9d4b7..1421acb8 100644 --- a/fdroidserver/index.py +++ b/fdroidserver/index.py @@ -36,8 +36,6 @@ from datetime import datetime from xml.dom.minidom import Document import requests -from pyasn1.codec.der import decoder, encoder -from pyasn1_modules import rfc2315 from fdroidserver import metadata, signindex, common from fdroidserver.common import FDroidPopen, FDroidPopenBytes @@ -621,23 +619,7 @@ def get_public_key_from_jar(jar): raise VerificationException("Found multiple signing certificates for repository.") # extract public key from certificate - public_key = get_public_key_from_certificate(jar.read(certs[0])) + public_key = common.get_certificate(jar.read(certs[0])) public_key_fingerprint = common.get_cert_fingerprint(public_key).replace(' ', '') return public_key, public_key_fingerprint - - -def get_public_key_from_certificate(certificate_file): - """ - Extracts a public key from the given certificate. - :param certificate_file: file bytes (as string) representing the certificate - :return: A binary representation of the certificate's public key - """ - content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0] - if content.getComponentByName('contentType') != rfc2315.signedData: - raise VerificationException("Unexpected certificate format.") - content = decoder.decode(content.getComponentByName('content'), - asn1Spec=rfc2315.SignedData())[0] - certificates = content.getComponentByName('certificates') - cert = certificates[0].getComponentByName('certificate') - return encoder.encode(cert) diff --git a/fdroidserver/update.py b/fdroidserver/update.py index 94f181dd..92075d8c 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -34,9 +34,6 @@ from datetime import datetime, timedelta from argparse import ArgumentParser import collections -from pyasn1.error import PyAsn1Error -from pyasn1.codec.der import decoder, encoder -from pyasn1_modules import rfc2315 from binascii import hexlify from PIL import Image @@ -45,7 +42,7 @@ import logging from . import common from . import index from . import metadata -from .common import FDroidPopen, SdkToolsPopen +from .common import SdkToolsPopen METADATA_VERSION = 18 @@ -389,17 +386,11 @@ def getsig(apkpath): if an error occurred. """ - cert = None - # verify the jar signature is correct - args = [config['jarsigner'], '-verify', apkpath] - p = FDroidPopen(args) - if p.returncode != 0: - logging.critical(apkpath + " has a bad signature!") + if not common.verify_apk_signature(apkpath): return None 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: @@ -411,20 +402,7 @@ def getsig(apkpath): cert = apk.read(certs[0]) - content = decoder.decode(cert, asn1Spec=rfc2315.ContentInfo())[0] - if content.getComponentByName('contentType') != rfc2315.signedData: - logging.error("Unexpected format.") - return None - - content = decoder.decode(content.getComponentByName('content'), - asn1Spec=rfc2315.SignedData())[0] - try: - certificates = content.getComponentByName('certificates') - except PyAsn1Error: - logging.error("Certificates not found.") - return None - - cert_encoded = encoder.encode(certificates)[4:] + cert_encoded = common.get_certificate(cert) return hashlib.md5(hexlify(cert_encoded)).hexdigest()