diff --git a/fdroid b/fdroid index 0663089b..0b483e01 100755 --- a/fdroid +++ b/fdroid @@ -44,6 +44,7 @@ commands = OrderedDict([ ("server", "Interact with the repo HTTP server"), ("signindex", "Sign indexes created using update --nosign"), ("btlog", "Update the binary transparency log for a URL"), + ("signatures", "Extract signatures from APKs"), ]) diff --git a/fdroidserver/common.py b/fdroidserver/common.py index 95b1b06c..0a4a586a 100644 --- a/fdroidserver/common.py +++ b/fdroidserver/common.py @@ -260,7 +260,7 @@ def read_config(opts, config_file='config.py'): if any(k in config for k in ["keystore", "keystorepass", "keypass"]): st = os.stat(config_file) if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO: - logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file)) + logging.warning("unsafe permissions on {0} (should be 0600)!".format(config_file)) fill_config_defaults(config) @@ -1706,6 +1706,21 @@ def isApkAndDebuggable(apkfile): return get_apk_debuggable_androguard(apkfile) +def get_apk_id_aapt(apkfile): + """Extrat identification information from APK using aapt. + + :param apkfile: path to an APK file. + :returns: triplet (appid, version code, version name) + """ + r = re.compile("package: name='(?P.*)' versionCode='(?P.*)' versionName='(?P.*)' platformBuildVersionName='.*'") + p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False) + for line in p.output.splitlines(): + m = r.match(line) + if m: + return m.group('appid'), m.group('vercode'), m.group('vername') + raise FDroidException("reading identification failed, APK invalid: '{}'".format(apkfile)) + + class PopenResult: def __init__(self): self.returncode = None @@ -1964,6 +1979,30 @@ def place_srclib(root_dir, number, libpath): apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)') +def metadata_get_sigdir(appid, vercode=None): + """Get signature directory for app""" + if vercode: + return os.path.join('metadata', appid, 'signatures', vercode) + else: + return os.path.join('metadata', appid, 'signatures') + + +def apk_extract_signatures(apkpath, outdir, manifest=True): + """Extracts a signature files from APK and puts them into target directory. + + :param apkpath: location of the apk + :param outdir: folder where the extracted signature files will be stored + :param manifest: (optionally) disable extracting manifest file + """ + with ZipFile(apkpath, 'r') as in_apk: + for f in in_apk.infolist(): + if apk_sigfile.match(f.filename) or \ + (manifest and f.filename == 'META-INF/MANIFEST.MF'): + newpath = os.path.join(outdir, os.path.basename(f.filename)) + with open(newpath, 'wb') as out_file: + out_file.write(in_apk.read(f.filename)) + + def verify_apks(signed_apk, unsigned_apk, tmp_dir): """Verify that two apks are the same diff --git a/fdroidserver/signatures.py b/fdroidserver/signatures.py new file mode 100644 index 00000000..298711ae --- /dev/null +++ b/fdroidserver/signatures.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2017, Michael Poehn +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from argparse import ArgumentParser + +import re +import os +import sys +import logging + +from . import common +from . import net +from .exception import FDroidException + + +def extract_signature(apkpath): + + if not os.path.exists(apkpath): + raise FDroidException("file APK does not exists '{}'".format(apkpath)) + if not common.verify_apk_signature(apkpath): + raise FDroidException("no valid signature in '{}'".format(apkpath)) + logging.debug('signature okay: %s', apkpath) + + appid, vercode, _ = common.get_apk_id_aapt(apkpath) + sigdir = common.metadata_get_sigdir(appid, vercode) + if not os.path.exists(sigdir): + os.makedirs(sigdir) + common.apk_extract_signatures(apkpath, sigdir) + + return sigdir + + +def extract(config, options): + + # Create tmp dir if missing... + tmp_dir = 'tmp' + if not os.path.exists(tmp_dir): + os.mkdir(tmp_dir) + + if not options.APK or len(options.APK) <= 0: + logging.critical('no APK supplied') + sys.exit(1) + + # iterate over supplied APKs downlaod and extract them... + httpre = re.compile('https?:\/\/') + for apk in options.APK: + try: + if os.path.isfile(apk): + sigdir = extract_signature(apk) + logging.info('fetched singatures for %s -> %s', apk, sigdir) + elif httpre.match(apk): + if apk.startswith('https') or options.no_check_https: + try: + tmp_apk = os.path.join(tmp_dir, 'signed.apk') + net.download_file(apk, tmp_apk) + sigdir = extract_signature(tmp_apk) + logging.info('fetched singatures for %s -> %s', apk, sigdir) + finally: + if tmp_apk and os.path.exists(tmp_apk): + os.remove(tmp_apk) + else: + logging.warn('refuse downloading via insecure http connection (use https or specify --no-https-check): %s', apk) + except FDroidException as e: + logging.warning("failed fetching signatures for '%s': %s", apk, e) + if e.detail: + logging.debug(e.detail) + + +def main(): + + global config, options + + # Parse command line... + parser = ArgumentParser(usage="%(prog)s [options] APK [APK...]") + common.setup_global_opts(parser) + parser.add_argument("APK", nargs='*', + help="signed APK, either a file-path or Https-URL are fine here.") + parser.add_argument("--no-check-https", action="store_true", default=False) + options = parser.parse_args() + + # Read config.py... + config = common.read_config(options) + + extract(config, options) diff --git a/tests/signatures.TestCase b/tests/signatures.TestCase new file mode 100755 index 00000000..42a69c7c --- /dev/null +++ b/tests/signatures.TestCase @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 + +import inspect +import optparse +import os +import sys +import unittest +import hashlib +import logging +from tempfile import TemporaryDirectory + +localmodule = os.path.realpath( + os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '..')) +print('localmodule: ' + localmodule) +if localmodule not in sys.path: + sys.path.insert(0, localmodule) + +from testcommon import TmpCwd +from fdroidserver import common, signatures + + +class SignaturesTest(unittest.TestCase): + + def setUp(self): + logging.basicConfig(level=logging.DEBUG) + common.config = None + config = common.read_config(common.options) + config['jarsigner'] = common.find_sdk_tools_cmd('jarsigner') + config['verbose'] = True + common.config = config + + def test_main(self): + + # option fixture class: + class OptionsFixture: + APK = [os.path.abspath(os.path.join('repo', 'com.politedroid_3.apk'))] + + with TemporaryDirectory() as tmpdir, TmpCwd(tmpdir): + signatures.extract(common.config, OptionsFixture()) + + # check if extracted signatures are where they are supposed to be + # also verify weather if extracted file contian what they should + filesAndHashes = ( + (os.path.join('metadata', 'com.politedroid', 'signatures', '3', 'MANIFEST.MF'), + '7dcd83f0c41a75457fd2311bf3c4578f80d684362d74ba8dc52838d353f31cf2'), + (os.path.join('metadata', 'com.politedroid', 'signatures', '3', 'RELEASE.RSA'), + '883ef3d5a6e0bf69d2a58d9e255a7930f08a49abc38e216ed054943c99c8fdb4'), + (os.path.join('metadata', 'com.politedroid', 'signatures', '3', 'RELEASE.SF'), + '99fbb3211ef5d7c1253f3a7ad4836eadc9905103ce6a75916c40de2831958284'), + ) + for path, checksum in filesAndHashes: + self.assertTrue(os.path.isfile(path)) + with open(path, 'rb') as f: + self.assertEqual(hashlib.sha256(f.read()).hexdigest(), checksum) + + +if __name__ == "__main__": + parser = optparse.OptionParser() + parser.add_option("-v", "--verbose", action="store_true", default=False, + help="Spew out even more information than normal") + (common.options, args) = parser.parse_args(['--verbose']) + + newSuite = unittest.TestSuite() + newSuite.addTest(unittest.makeSuite(SignaturesTest)) + unittest.main() diff --git a/tests/testcommon.py b/tests/testcommon.py new file mode 100644 index 00000000..a637012e --- /dev/null +++ b/tests/testcommon.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2017, Michael Poehn +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import os + + +class TmpCwd(): + """Context-manager for temporarily changing the current working + directory. + """ + + def __init__(self, new_cwd): + self.new_cwd = new_cwd + + def __enter__(self): + self.orig_cwd = os.getcwd() + os.chdir(self.new_cwd) + + def __exit__(self, a, b, c): + os.chdir(self.orig_cwd)