chiark / gitweb /
Merge branch 'ndk' into 'master'
[fdroidserver.git] / fdroidserver / common.py
index b5e06280e27714eb79881d334058c6dc5d92607d..c8fdaad529de3f49e1d9bb12b00d649a2da40583 100644 (file)
@@ -36,14 +36,28 @@ import socket
 import base64
 import xml.etree.ElementTree as XMLElementTree
 
+from binascii import hexlify
+from datetime import datetime
 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
+
+from distutils.util import strtobool
+
 import fdroidserver.metadata
+from fdroidserver.exception import FDroidException, VCSException, BuildException
 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)$')
+APK_NAME_REGEX = re.compile(r'^([a-zA-Z][\w.]*)_(-?[0-9]+)_?([0-9a-f]{7})?\.apk')
+STANDARD_FILE_NAME_REGEX = re.compile(r'^(\w[\w.]*)_(-?[0-9]+)\.\w+')
+
 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
 
 config = None
@@ -60,6 +74,8 @@ default_config = {
         'r11c': None,
         'r12b': "$ANDROID_NDK",
         'r13b': None,
+        'r14b': None,
+        'r15b': None,
     },
     'qt_sdk_path': None,
     'build_tools': "25.0.2",
@@ -70,6 +86,7 @@ default_config = {
     'gradle': 'gradle',
     'accepted_formats': ['txt', 'yml'],
     'sync_from_local_copy_dir': False,
+    'allow_disabled_algorithms': False,
     'per_app_repos': False,
     'make_current_version_link': True,
     'current_version_name_source': 'Name',
@@ -83,8 +100,12 @@ default_config = {
     'keystore': 'keystore.jks',
     'smartcardoptions': [],
     'char_limits': {
-        'Summary': 80,
-        'Description': 4000,
+        'author': 256,
+        'name': 30,
+        'summary': 80,
+        'description': 4000,
+        'video': 256,
+        'whatsNew': 500,
     },
     'keyaliases': {},
     'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
@@ -218,8 +239,13 @@ def read_config(opts, config_file='config.py'):
             code = compile(f.read(), config_file, 'exec')
             exec(code, None, config)
     elif len(get_local_metadata_files()) == 0:
-        logging.critical("Missing config file - is this a repo directory?")
-        sys.exit(2)
+        raise FDroidException("Missing config file - is this a repo directory?")
+
+    for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
+        if k in config:
+            if not type(config[k]) in (str, list, tuple):
+                logging.warn('"' + k + '" will be in random order!'
+                             + ' Use () or [] brackets if order is important!')
 
     # smartcardoptions must be a list since its command line args for Popen
     if 'smartcardoptions' in config:
@@ -238,10 +264,6 @@ def read_config(opts, config_file='config.py'):
 
     fill_config_defaults(config)
 
-    for k in ["keystorepass", "keypass"]:
-        if k in config:
-            write_password_file(k)
-
     for k in ["repo_description", "archive_description"]:
         if k in config:
             config[k] = clean_description(config[k])
@@ -262,6 +284,15 @@ def read_config(opts, config_file='config.py'):
             rootlist.append(rootstr.replace('//', '/'))
         config['serverwebroot'] = rootlist
 
+    if 'servergitmirrors' in config:
+        if isinstance(config['servergitmirrors'], str):
+            roots = [config['servergitmirrors']]
+        elif all(isinstance(item, str) for item in config['servergitmirrors']):
+            roots = config['servergitmirrors']
+        else:
+            raise TypeError('only accepts strings, lists, and tuples')
+        config['servergitmirrors'] = roots
+
     return config
 
 
@@ -347,28 +378,12 @@ def test_sdk_exists(thisconfig):
 
 def ensure_build_tools_exists(thisconfig):
     if not test_sdk_exists(thisconfig):
-        sys.exit(3)
+        raise FDroidException("Android SDK not found.")
     build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
     versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
     if not os.path.isdir(versioned_build_tools):
-        logging.critical('Android Build Tools path "'
-                         + versioned_build_tools + '" does not exist!')
-        sys.exit(3)
-
-
-def write_password_file(pwtype, password=None):
-    '''
-    writes out passwords to a protected file instead of passing passwords as
-    command line argments
-    '''
-    filename = '.fdroid.' + pwtype + '.txt'
-    fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
-    if password is None:
-        os.write(fd, config[pwtype].encode('utf-8'))
-    else:
-        os.write(fd, password.encode('utf-8'))
-    os.close(fd)
-    config[pwtype + 'file'] = filename
+        raise FDroidException(
+            'Android Build Tools path "' + versioned_build_tools + '" does not exist!')
 
 
 def get_local_metadata_files():
@@ -383,8 +398,8 @@ def get_local_metadata_files():
 
 def read_pkg_args(args, allow_vercodes=False):
     """
-    Given the arguments in the form of multiple appid:[vc] strings, this returns
-    a dictionary with the set of vercodes specified for each package.
+    :param args: arguments in the form of multiple appid:[vc] strings
+    :returns: a dictionary with the set of vercodes specified for each package
     """
 
     vercodes = {}
@@ -434,10 +449,10 @@ def read_app_args(args, allapps, allow_vercodes=False):
         vc = vercodes[appid]
         if not vc:
             continue
-        app.builds = [b for b in app.builds if b.vercode in vc]
+        app.builds = [b for b in app.builds if b.versionCode in vc]
         if len(app.builds) != len(vercodes[appid]):
             error = True
-            allvcs = [b.vercode for b in app.builds]
+            allvcs = [b.versionCode for b in app.builds]
             for v in vercodes[appid]:
                 if v not in allvcs:
                     logging.critical("No such vercode %s for app %s" % (v, appid))
@@ -460,7 +475,7 @@ def has_extension(filename, ext):
     return ext == f_ext
 
 
-apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
+publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
 
 
 def clean_description(description):
@@ -476,25 +491,29 @@ def clean_description(description):
     return returnstring.rstrip('\n')
 
 
-def apknameinfo(filename):
+def publishednameinfo(filename):
     filename = os.path.basename(filename)
-    m = apk_regex.match(filename)
+    m = publish_name_regex.match(filename)
     try:
         result = (m.group(1), m.group(2))
     except AttributeError:
-        raise FDroidException("Invalid apk name: %s" % filename)
+        raise FDroidException("Invalid name for published file: %s" % filename)
     return result
 
 
 def get_release_filename(app, build):
     if build.output:
-        return "%s_%s.%s" % (app.id, build.vercode, get_file_extension(build.output))
+        return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
     else:
-        return "%s_%s.apk" % (app.id, build.vercode)
+        return "%s_%s.apk" % (app.id, build.versionCode)
+
+
+def get_toolsversion_logname(app, build):
+    return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
 
 
 def getsrcname(app, build):
-    return "%s_%s_src.tar.gz" % (app.id, build.vercode)
+    return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
 
 
 def getappname(app):
@@ -640,7 +659,7 @@ class vcs:
 
     # Derived classes need to implement this. It's called once basic checking
     # has been performend.
-    def gotorevisionx(self, rev):
+    def gotorevisionx(self, rev):  # pylint: disable=unused-argument
         raise VCSException("This VCS type doesn't define gotorevisionx")
 
     # Initialise and update submodules
@@ -1108,8 +1127,8 @@ def remove_debuggable_flags(root_dir):
                         os.path.join(root, 'AndroidManifest.xml'))
 
 
-vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
-vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
+vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
+vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
 
 
@@ -1145,12 +1164,11 @@ def parse_androidmanifests(paths, app):
             continue
 
         logging.debug("Parsing manifest at {0}".format(path))
-        gradle = has_extension(path, 'gradle')
         version = None
         vercode = None
         package = None
 
-        if gradle:
+        if has_extension(path, 'gradle'):
             with open(path, 'r') as f:
                 for line in f:
                     if gradle_comment.match(line):
@@ -1203,7 +1221,8 @@ def parse_androidmanifests(paths, app):
         if max_version is None and version is not None:
             max_version = version
 
-        if max_vercode is None or (vercode is not None and vercode > max_vercode):
+        if vercode is not None \
+           and (max_vercode is None or vercode > max_vercode):
             if not ignoresearch or not ignoresearch(version):
                 if version is not None:
                     max_version = version
@@ -1227,39 +1246,6 @@ def is_valid_package_name(name):
     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
 
 
-class FDroidException(Exception):
-
-    def __init__(self, value, detail=None):
-        self.value = value
-        self.detail = detail
-
-    def shortened_detail(self):
-        if len(self.detail) < 16000:
-            return self.detail
-        return '[...]\n' + self.detail[-16000:]
-
-    def get_wikitext(self):
-        ret = repr(self.value) + "\n"
-        if self.detail:
-            ret += "=detail=\n"
-            ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
-        return ret
-
-    def __str__(self):
-        ret = self.value
-        if self.detail:
-            ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
-        return ret
-
-
-class VCSException(FDroidException):
-    pass
-
-
-class BuildException(FDroidException):
-    pass
-
-
 # Get the specified source library.
 # Returns the path to it. Normally this is the path to be used when referencing
 # it, which may be a subdirectory of the actual project. If you want the base
@@ -1375,7 +1361,7 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=
         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
         if p.returncode != 0:
             raise BuildException("Error running init command for %s:%s" %
-                                 (app.id, build.version), p.output)
+                                 (app.id, build.versionName), p.output)
 
     # Apply patches if any
     if build.patch:
@@ -1466,11 +1452,11 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=
                 continue
             if has_extension(path, 'xml'):
                 regsub_file(r'android:versionName="[^"]*"',
-                            r'android:versionName="%s"' % build.version,
+                            r'android:versionName="%s"' % build.versionName,
                             path)
             elif has_extension(path, 'gradle'):
                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
-                            r"""\1versionName '%s'""" % build.version,
+                            r"""\1versionName '%s'""" % build.versionName,
                             path)
 
     if build.forcevercode:
@@ -1480,11 +1466,11 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=
                 continue
             if has_extension(path, 'xml'):
                 regsub_file(r'android:versionCode="[^"]*"',
-                            r'android:versionCode="%s"' % build.vercode,
+                            r'android:versionCode="%s"' % build.versionCode,
                             path)
             elif has_extension(path, 'gradle'):
                 regsub_file(r'versionCode[ =]+[0-9]+',
-                            r'versionCode %s' % build.vercode,
+                            r'versionCode %s' % build.versionCode,
                             path)
 
     # Delete unwanted files
@@ -1532,18 +1518,18 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=
         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
         if p.returncode != 0:
             raise BuildException("Error running prebuild command for %s:%s" %
-                                 (app.id, build.version), p.output)
+                                 (app.id, build.versionName), p.output)
 
     # Generate (or update) the ant build file, build.xml...
-    if build.build_method() == 'ant' and build.update != ['no']:
+    if build.build_method() == 'ant' and build.androidupdate != ['no']:
         parms = ['android', 'update', 'lib-project']
         lparms = ['android', 'update', 'project']
 
         if build.target:
             parms += ['-t', build.target]
             lparms += ['-t', build.target]
-        if build.update:
-            update_dirs = build.update
+        if build.androidupdate:
+            update_dirs = build.androidupdate
         else:
             update_dirs = ant_subprojects(root_dir) + ['.']
 
@@ -1598,6 +1584,11 @@ def natural_key(s):
 
 
 class KnownApks:
+    """permanent store of existing APKs with the date they were added
+
+    This is currently the only way to permanently store the "updated"
+    date of APKs.
+    """
 
     def __init__(self):
         self.path = os.path.join('stats', 'known_apks.txt')
@@ -1609,7 +1600,7 @@ class KnownApks:
                     if len(t) == 2:
                         self.apks[t[0]] = (t[1], None)
                     else:
-                        self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
+                        self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
         self.changed = False
 
     def writeifchanged(self):
@@ -1624,22 +1615,24 @@ class KnownApks:
             appid, added = app
             line = apk + ' ' + appid
             if added:
-                line += ' ' + time.strftime('%Y-%m-%d', added)
+                line += ' ' + added.strftime('%Y-%m-%d')
             lst.append(line)
 
         with open(self.path, 'w', encoding='utf8') as f:
             for line in sorted(lst, key=natural_key):
                 f.write(line + '\n')
 
-    # Record an apk (if it's new, otherwise does nothing)
-    # Returns the date it was added.
-    def recordapk(self, apk, app, default_date=None):
-        if apk not in self.apks:
+    def recordapk(self, apkName, app, default_date=None):
+        '''
+        Record an apk (if it's new, otherwise does nothing)
+        Returns the date it was added as a datetime instance
+        '''
+        if apkName not in self.apks:
             if default_date is None:
-                default_date = time.gmtime(time.time())
-            self.apks[apk] = (app, default_date)
+                default_date = datetime.utcnow()
+            self.apks[apkName] = (app, default_date)
             self.changed = True
-        _, added = self.apks[apk]
+        _, added = self.apks[apkName]
         return added
 
     # Look up information - given the 'apkname', returns (app id, date added/None).
@@ -1669,29 +1662,50 @@ class KnownApks:
 
 def get_file_extension(filename):
     """get the normalized file extension, can be blank string but never None"""
-
+    if isinstance(filename, bytes):
+        filename = filename.decode('utf-8')
     return os.path.splitext(filename)[1].lower()[1:]
 
 
-def isApkAndDebuggable(apkfile, config):
-    """Returns True if the given file is an APK and is debuggable
-
-    :param apkfile: full path to the apk to check"""
-
-    if get_file_extension(apkfile) != 'apk':
-        return False
-
+def get_apk_debuggable_aapt(apkfile):
     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
                       output=False)
     if p.returncode != 0:
-        logging.critical("Failed to get apk manifest information")
-        sys.exit(1)
+        raise FDroidException("Failed to get apk manifest information")
     for line in p.output.splitlines():
         if 'android:debuggable' in line and not line.endswith('0x0'):
             return True
     return False
 
 
+def get_apk_debuggable_androguard(apkfile):
+    try:
+        from androguard.core.bytecodes.apk import APK
+    except ImportError:
+        raise FDroidException("androguard library is not installed and aapt not present")
+
+    apkobject = APK(apkfile)
+    if apkobject.is_valid_APK():
+        debuggable = apkobject.get_element("application", "debuggable")
+        if debuggable is not None:
+            return bool(strtobool(debuggable))
+    return False
+
+
+def isApkAndDebuggable(apkfile):
+    """Returns True if the given file is an APK and is debuggable
+
+    :param apkfile: full path to the apk to check"""
+
+    if get_file_extension(apkfile) != 'apk':
+        return False
+
+    if SdkToolsPopen(['aapt', 'version'], output=False):
+        return get_apk_debuggable_aapt(apkfile)
+    else:
+        return get_apk_debuggable_androguard(apkfile)
+
+
 class PopenResult:
     def __init__(self):
         self.returncode = None
@@ -1704,20 +1718,20 @@ def SdkToolsPopen(commands, cwd=None, output=True):
         config[cmd] = find_sdk_tools_cmd(commands[0])
     abscmd = config[cmd]
     if abscmd is None:
-        logging.critical("Could not find '%s' on your system" % cmd)
-        sys.exit(1)
+        raise FDroidException("Could not find '%s' on your system" % cmd)
     if cmd == 'aapt':
         test_aapt_version(config['aapt'])
     return FDroidPopen([abscmd] + commands[1:],
                        cwd=cwd, output=output)
 
 
-def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
+def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
     """
     Run a command and capture the possibly huge output as bytes.
 
     :param commands: command and argument list like in subprocess.Popen
     :param cwd: optionally specifies a working directory
+    :param envs: a optional dictionary of environment variables and their values
     :returns: A PopenResult.
     """
 
@@ -1725,6 +1739,10 @@ def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
     if env is None:
         set_FDroidPopen_env()
 
+    process_env = env.copy()
+    if envs is not None and len(envs) > 0:
+        process_env.update(envs)
+
     if cwd:
         cwd = os.path.normpath(cwd)
         logging.debug("Directory: %s" % cwd)
@@ -1734,7 +1752,7 @@ def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
     result = PopenResult()
     p = None
     try:
-        p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
+        p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
                              stdout=subprocess.PIPE, stderr=stderr_param)
     except OSError as e:
         raise BuildException("OSError while trying to execute " +
@@ -1774,16 +1792,17 @@ def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
     return result
 
 
-def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
+def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
     """
     Run a command and capture the possibly huge output as a str.
 
     :param commands: command and argument list like in subprocess.Popen
     :param cwd: optionally specifies a working directory
+    :param envs: a optional dictionary of environment variables and their values
     :returns: A PopenResult.
     """
-    result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
-    result.output = result.output.decode('utf-8')
+    result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
+    result.output = result.output.decode('utf-8', 'ignore')
     return result
 
 
@@ -1902,15 +1921,20 @@ def set_FDroidPopen_env(build=None):
             env[n] = build.ndk_path()
 
 
+def replace_build_vars(cmd, build):
+    cmd = cmd.replace('$$COMMIT$$', build.commit)
+    cmd = cmd.replace('$$VERSION$$', build.versionName)
+    cmd = cmd.replace('$$VERCODE$$', build.versionCode)
+    return cmd
+
+
 def replace_config_vars(cmd, build):
     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
     cmd = cmd.replace('$$NDK$$', build.ndk_path())
     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
     cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
     if build is not None:
-        cmd = cmd.replace('$$COMMIT$$', build.commit)
-        cmd = cmd.replace('$$VERSION$$', build.version)
-        cmd = cmd.replace('$$VERCODE$$', build.vercode)
+        cmd = replace_build_vars(cmd, build)
     return cmd
 
 
@@ -1946,36 +1970,103 @@ def verify_apks(signed_apk, unsigned_apk, tmp_dir):
     One of the inputs is signed, the other is unsigned. The signature metadata
     is transferred from the signed to the unsigned apk, and then jarsigner is
     used to verify that the signature from the signed apk is also varlid for
-    the unsigned one.
+    the unsigned one.  If the APK given as unsigned actually does have a
+    signature, it will be stripped out and ignored.
+
+    There are two SHA1 git commit IDs that fdroidserver includes in the builds
+    it makes: fdroidserverid and buildserverid.  Originally, these were inserted
+    into AndroidManifest.xml, but that makes the build not reproducible. So
+    instead they are included as separate files in the APK's META-INF/ folder.
+    If those files exist in the signed APK, they will be part of the signature
+    and need to also be included in the unsigned APK for it to validate.
+
     :param signed_apk: Path to a signed apk file
     :param unsigned_apk: Path to an unsigned apk file expected to match it
     :param tmp_dir: Path to directory for temporary files
     :returns: None if the verification is successful, otherwise a string
               describing what went wrong.
     """
-    with ZipFile(signed_apk) as signed_apk_as_zip:
-        meta_inf_files = ['META-INF/MANIFEST.MF']
-        for f in signed_apk_as_zip.namelist():
-            if apk_sigfile.match(f):
-                meta_inf_files.append(f)
-        if len(meta_inf_files) < 3:
-            return "Signature files missing from {0}".format(signed_apk)
-        signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
-    with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
-        for meta_inf_file in meta_inf_files:
-            unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
-
-    if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
-        logging.info("...NOT verified - {0}".format(signed_apk))
-        return compare_apks(signed_apk, unsigned_apk, tmp_dir)
+
+    signed = ZipFile(signed_apk, 'r')
+    meta_inf_files = ['META-INF/MANIFEST.MF']
+    for f in signed.namelist():
+        if apk_sigfile.match(f) \
+           or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
+            meta_inf_files.append(f)
+    if len(meta_inf_files) < 3:
+        return "Signature files missing from {0}".format(signed_apk)
+
+    tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
+    unsigned = ZipFile(unsigned_apk, 'r')
+    # only read the signature from the signed APK, everything else from unsigned
+    with ZipFile(tmp_apk, 'w') as tmp:
+        for filename in meta_inf_files:
+            tmp.writestr(signed.getinfo(filename), signed.read(filename))
+        for info in unsigned.infolist():
+            if info.filename in meta_inf_files:
+                logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
+                continue
+            if info.filename in tmp.namelist():
+                return "duplicate filename found: " + info.filename
+            tmp.writestr(info, unsigned.read(info.filename))
+    unsigned.close()
+    signed.close()
+
+    verified = verify_apk_signature(tmp_apk)
+
+    if not verified:
+        logging.info("...NOT verified - {0}".format(tmp_apk))
+        return compare_apks(signed_apk, tmp_apk, tmp_dir,
+                            os.path.dirname(unsigned_apk))
+
     logging.info("...successfully verified")
     return None
 
 
+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'):
+        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
+
+
+def verify_old_apk_signature(apk):
+    """verify the signature on an archived APK, supporting deprecated algorithms
+
+    F-Droid aims to keep every single binary that it ever published.  Therefore,
+    it needs to be able to verify APK signatures that include deprecated/removed
+    algorithms.  For example, jarsigner treats an MD5 signature as unsigned.
+
+    jarsigner passes unsigned APKs as "verified"! So this has to turn
+    on -strict then check for result 4.
+
+    """
+
+    _java_security = os.path.join(os.getcwd(), '.java.security')
+    with open(_java_security, 'w') as fp:
+        fp.write('jdk.jar.disabledAlgorithms=MD2, RSA keySize < 1024')
+
+    return subprocess.call([config['jarsigner'], '-J-Djava.security.properties=' + _java_security,
+                            '-strict', '-verify', apk]) == 4
+
+
 apk_badchars = re.compile('''[/ :;'"]''')
 
 
-def compare_apks(apk1, apk2, tmp_dir):
+def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
     """Compare two apks
 
     Returns None if the apk content is the same (apart from the signing key),
@@ -1983,6 +2074,22 @@ def compare_apks(apk1, apk2, tmp_dir):
     trying to do the comparison.
     """
 
+    if not log_dir:
+        log_dir = tmp_dir
+
+    absapk1 = os.path.abspath(apk1)
+    absapk2 = os.path.abspath(apk2)
+
+    if set_command_in_config('diffoscope'):
+        logfilename = os.path.join(log_dir, os.path.basename(absapk1))
+        htmlfile = logfilename + '.diffoscope.html'
+        textfile = logfilename + '.diffoscope.txt'
+        if subprocess.call([config['diffoscope'],
+                            '--max-report-size', '12345678', '--max-diff-block-lines', '100',
+                            '--html', htmlfile, '--text', textfile,
+                            absapk1, absapk2]) != 0:
+            return("Failed to unpack " + apk1)
+
     apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4]))  # trim .apk
     apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4]))  # trim .apk
     for d in [apk1dir, apk2dir]:
@@ -2000,12 +2107,7 @@ def compare_apks(apk1, apk2, tmp_dir):
                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
         return("Failed to unpack " + apk2)
 
-    # try to find apktool in the path, if it hasn't been manually configed
-    if 'apktool' not in config:
-        tmp = find_command('apktool')
-        if tmp is not None:
-            config['apktool'] = tmp
-    if 'apktool' in config:
+    if set_command_in_config('apktool'):
         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
                            cwd=apk1dir) != 0:
             return("Failed to unpack " + apk1)
@@ -2016,9 +2118,8 @@ def compare_apks(apk1, apk2, tmp_dir):
     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
     lines = p.output.splitlines()
     if len(lines) != 1 or 'META-INF' not in lines[0]:
-        meld = find_command('meld')
-        if meld is not None:
-            p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
+        if set_command_in_config('meld'):
+            p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
         return("Unexpected diff output - " + p.output)
 
     # since everything verifies, delete the comparison to keep cruft down
@@ -2029,6 +2130,22 @@ def compare_apks(apk1, apk2, tmp_dir):
     return None
 
 
+def set_command_in_config(command):
+    '''Try to find specified command in the path, if it hasn't been
+    manually set in config.py.  If found, it is added to the config
+    dict.  The return value says whether the command is available.
+
+    '''
+    if command in config:
+        return True
+    else:
+        tmp = find_command(command)
+        if tmp is not None:
+            config[command] = tmp
+            return True
+    return False
+
+
 def find_command(command):
     '''find the full path of a command, or None if it can't be found in the PATH'''
 
@@ -2059,7 +2176,10 @@ def genpassword():
 
 
 def genkeystore(localconfig):
-    '''Generate a new key with random passwords and add it to new keystore'''
+    """
+    Generate a new key with password provided in :param localconfig and add it to new keystore
+    :return: hexed public key, public key fingerprint
+    """
     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
     keystoredir = os.path.dirname(localconfig['keystore'])
     if keystoredir is None or keystoredir == '':
@@ -2067,47 +2187,122 @@ def genkeystore(localconfig):
     if not os.path.exists(keystoredir):
         os.makedirs(keystoredir, mode=0o700)
 
-    write_password_file("keystorepass", localconfig['keystorepass'])
-    write_password_file("keypass", localconfig['keypass'])
+    env_vars = {
+        'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
+        'FDROID_KEY_PASS': localconfig['keypass'],
+    }
     p = FDroidPopen([config['keytool'], '-genkey',
                      '-keystore', localconfig['keystore'],
                      '-alias', localconfig['repo_keyalias'],
                      '-keyalg', 'RSA', '-keysize', '4096',
                      '-sigalg', 'SHA256withRSA',
                      '-validity', '10000',
-                     '-storepass:file', config['keystorepassfile'],
-                     '-keypass:file', config['keypassfile'],
-                     '-dname', localconfig['keydname']])
-    # TODO keypass should be sent via stdin
+                     '-storepass:env', 'FDROID_KEY_STORE_PASS',
+                     '-keypass:env', 'FDROID_KEY_PASS',
+                     '-dname', localconfig['keydname']], envs=env_vars)
     if p.returncode != 0:
         raise BuildException("Failed to generate key", p.output)
     os.chmod(localconfig['keystore'], 0o0600)
-    # now show the lovely key that was just generated
-    p = FDroidPopen([config['keytool'], '-list', '-v',
-                     '-keystore', localconfig['keystore'],
-                     '-alias', localconfig['repo_keyalias'],
-                     '-storepass:file', config['keystorepassfile']])
-    logging.info(p.output.strip() + '\n\n')
+    if not options.quiet:
+        # now show the lovely key that was just generated
+        p = FDroidPopen([config['keytool'], '-list', '-v',
+                         '-keystore', localconfig['keystore'],
+                         '-alias', localconfig['repo_keyalias'],
+                         '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
+        logging.info(p.output.strip() + '\n\n')
+    # get the public key
+    p = FDroidPopenBytes([config['keytool'], '-exportcert',
+                          '-keystore', localconfig['keystore'],
+                          '-alias', localconfig['repo_keyalias'],
+                          '-storepass:env', 'FDROID_KEY_STORE_PASS']
+                         + config['smartcardoptions'],
+                         envs=env_vars, output=False, stderr_to_stdout=False)
+    if p.returncode != 0 or len(p.output) < 20:
+        raise BuildException("Failed to get public key", p.output)
+    pubkey = p.output
+    fingerprint = get_cert_fingerprint(pubkey)
+    return hexlify(pubkey), fingerprint
+
+
+def get_cert_fingerprint(pubkey):
+    """
+    Generate a certificate fingerprint the same way keytool does it
+    (but with slightly different formatting)
+    """
+    digest = hashlib.sha256(pubkey).digest()
+    ret = [' '.join("%02X" % b for b in bytearray(digest))]
+    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
 
-def write_to_config(thisconfig, key, value=None):
-    '''write a key/value to the local config.py'''
+    NOTE: only supports writing string variables.
+
+    :param thisconfig: config dictionary
+    :param key: variable name in config.py to be overwritten/added
+    :param value: optional value to be written, instead of fetched
+        from 'thisconfig' dictionary.
+    '''
     if value is None:
         origkey = key + '_orig'
         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
-    with open('config.py', 'r', encoding='utf8') as f:
-        data = f.read()
-    pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
-    repl = '\n' + key + ' = "' + value + '"'
-    data = re.sub(pattern, repl, data)
-    # if this key is not in the file, append it
-    if not re.match('\s*' + key + '\s*=\s*"', data):
-        data += repl
+    cfg = config_file if config_file else 'config.py'
+
+    # load config file
+    with open(cfg, 'r', encoding="utf-8") as f:
+        lines = f.readlines()
+
     # make sure the file ends with a carraige return
-    if not re.match('\n$', data):
-        data += '\n'
-    with open('config.py', 'w', encoding='utf8') as f:
-        f.writelines(data)
+    if len(lines) > 0:
+        if not lines[-1].endswith('\n'):
+            lines[-1] += '\n'
+
+    # regex for finding and replacing python string variable
+    # definitions/initializations
+    pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
+    repl = key + ' = "' + value + '"'
+    pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
+    repl2 = key + " = '" + value + "'"
+
+    # If we replaced this line once, we make sure won't be a
+    # second instance of this line for this key in the document.
+    didRepl = False
+    # edit config file
+    with open(cfg, 'w', encoding="utf-8") as f:
+        for line in lines:
+            if pattern.match(line) or pattern2.match(line):
+                if not didRepl:
+                    line = pattern.sub(repl, line)
+                    line = pattern2.sub(repl2, line)
+                    f.write(line)
+                    didRepl = True
+            else:
+                f.write(line)
+        if not didRepl:
+            f.write('\n')
+            f.write(repl)
+            f.write('\n')
 
 
 def parse_xml(path):
@@ -2147,10 +2342,17 @@ def get_per_app_repos():
 
 def is_repo_file(filename):
     '''Whether the file in a repo is a build product to be delivered to users'''
+    if isinstance(filename, str):
+        filename = filename.encode('utf-8', errors="surrogateescape")
     return os.path.isfile(filename) \
+        and not filename.endswith(b'.asc') \
+        and not filename.endswith(b'.sig') \
         and os.path.basename(filename) not in [
-            'index.jar',
-            'index.xml',
-            'index.html',
-            'categories.txt',
+            b'index.jar',
+            b'index_unsigned.jar',
+            b'index.xml',
+            b'index.html',
+            b'index-v1.jar',
+            b'index-v1.json',
+            b'categories.txt',
         ]