1
0
mirror of https://gitlab.com/fdroid/fdroidserver.git synced 2024-10-06 19:10:12 +02:00

Merge branch '291-include-apk-signatures-in-build-metadata-file' into 'master'

include APK signatures in build metadata file

Closes #291

See merge request fdroid/fdroidserver!287
This commit is contained in:
Hans-Christoph Steiner 2017-10-03 12:57:34 +00:00
commit ad10bfcad2
17 changed files with 28134 additions and 100 deletions

View File

@ -34,6 +34,9 @@ import logging
import hashlib import hashlib
import socket import socket
import base64 import base64
import zipfile
import tempfile
import json
import xml.etree.ElementTree as XMLElementTree import xml.etree.ElementTree as XMLElementTree
from binascii import hexlify from binascii import hexlify
@ -509,6 +512,32 @@ def publishednameinfo(filename):
return result return result
apk_release_filename = re.compile('(?P<appid>[a-zA-Z0-9_\.]+)_(?P<vercode>[0-9]+)\.apk')
apk_release_filename_with_sigfp = re.compile('(?P<appid>[a-zA-Z0-9_\.]+)_(?P<vercode>[0-9]+)_(?P<sigfp>[0-9a-f]{7})\.apk')
def apk_parse_release_filename(apkname):
"""Parses the name of an APK file according the F-Droids APK naming
scheme and returns the tokens.
WARNING: Returned values don't necessarily represent the APKs actual
properties, the are just paresed from the file name.
:returns: A triplet containing (appid, versionCode, signer), where appid
should be the package name, versionCode should be the integer
represion of the APKs version and signer should be the first 7 hex
digists of the sha256 signing key fingerprint which was used to sign
this APK.
"""
m = apk_release_filename_with_sigfp.match(apkname)
if m:
return m.group('appid'), m.group('vercode'), m.group('sigfp')
m = apk_release_filename.match(apkname)
if m:
return m.group('appid'), m.group('vercode'), None
return None, None, None
def get_release_filename(app, build): def get_release_filename(app, build):
if build.output: if build.output:
return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output)) return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
@ -2014,6 +2043,67 @@ def place_srclib(root_dir, number, libpath):
apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)') apk_sigfile = 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.
Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint
for a given pkcs7 signature.
:param sig: Contents of an APK signing certificate.
:returns: shortened signing-key fingerprint.
"""
return signer_fingerprint(sig)[:7]
def signer_fingerprint(sig):
"""Obtain sha256 signing-key fingerprint for pkcs7 signature.
Extracts hexadecimal sha256 signing-key fingerprint string
for a given pkcs7 signature.
:param: Contents of an APK signature.
:returns: shortened signature fingerprint.
"""
cert_encoded = get_certificate(sig)
return hashlib.sha256(cert_encoded).hexdigest()
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
: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)
def apk_signer_fingerprint_short(apk_path):
"""Obtain shortened sha256 signing-key fingerprint for APK.
Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint
for a given pkcs7 APK.
:param apk_path: path to APK
:returns: shortened signing-key fingerprint
"""
return apk_signer_fingerprint(apk_path)[:7]
def metadata_get_sigdir(appid, vercode=None): def metadata_get_sigdir(appid, vercode=None):
"""Get signature directory for app""" """Get signature directory for app"""
if vercode: if vercode:
@ -2022,6 +2112,128 @@ def metadata_get_sigdir(appid, vercode=None):
return os.path.join('metadata', appid, 'signatures') return os.path.join('metadata', appid, 'signatures')
def metadata_find_developer_signature(appid, vercode=None):
"""Tires to find the developer signature for given appid.
This picks the first signature file found in metadata an returns its
signature.
:returns: sha256 signing key fingerprint of the developer signing key.
None in case no signature can not be found."""
# fetch list of dirs for all versions of signatures
appversigdirs = []
if vercode:
appversigdirs.append(metadata_get_sigdir(appid, vercode))
else:
appsigdir = metadata_get_sigdir(appid)
if os.path.isdir(appsigdir):
numre = re.compile('[0-9]+')
for ver in os.listdir(appsigdir):
if numre.match(ver):
appversigdir = os.path.join(appsigdir, ver)
appversigdirs.append(appversigdir)
for sigdir in appversigdirs:
sigs = glob.glob(os.path.join(sigdir, '*.DSA')) + \
glob.glob(os.path.join(sigdir, '*.EC')) + \
glob.glob(os.path.join(sigdir, '*.RSA'))
if len(sigs) > 1:
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 None
def metadata_find_signing_files(appid, vercode):
"""Gets a list of singed manifests and signatures.
:param appid: app id string
:param vercode: app version code
:returns: a list of triplets for each signing key with following paths:
(signature_file, singed_file, manifest_file)
"""
ret = []
sigdir = metadata_get_sigdir(appid, vercode)
sigs = glob.glob(os.path.join(sigdir, '*.DSA')) + \
glob.glob(os.path.join(sigdir, '*.EC')) + \
glob.glob(os.path.join(sigdir, '*.RSA'))
extre = re.compile('(\.DSA|\.EC|\.RSA)$')
for sig in sigs:
sf = extre.sub('.SF', sig)
if os.path.isfile(sf):
mf = os.path.join(sigdir, 'MANIFEST.MF')
if os.path.isfile(mf):
ret.append((sig, sf, mf))
return ret
def metadata_find_developer_signing_files(appid, vercode):
"""Get developer signature files for specified app from metadata.
:returns: A triplet of paths for signing files from metadata:
(signature_file, singed_file, manifest_file)
"""
allsigningfiles = metadata_find_signing_files(appid, vercode)
if allsigningfiles and len(allsigningfiles) == 1:
return allsigningfiles[0]
else:
return None
def apk_strip_signatures(signed_apk, strip_manifest=False):
"""Removes signatures from APK.
:param signed_apk: path to apk file.
:param strip_manifest: when set to True also the manifest file will
be removed from the APK.
"""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_apk = os.path.join(tmpdir, 'tmp.apk')
os.rename(signed_apk, tmp_apk)
with ZipFile(tmp_apk, 'r') as in_apk:
with ZipFile(signed_apk, 'w') as out_apk:
for f in in_apk.infolist():
if not apk_sigfile.match(f.filename):
if strip_manifest:
if f.filename != 'META-INF/MANIFEST.MF':
buf = in_apk.read(f.filename)
out_apk.writestr(f.filename, buf)
else:
buf = in_apk.read(f.filename)
out_apk.writestr(f.filename, buf)
def apk_implant_signatures(apkpath, signaturefile, signedfile, manifest):
"""Implats a signature from metadata into an APK.
Note: this changes there supplied APK in place. So copy it if you
need the original to be preserved.
:param apkpath: location of the apk
"""
# get list of available signature files in metadata
with tempfile.TemporaryDirectory() as tmpdir:
# orig_apk = os.path.join(tmpdir, 'orig.apk')
# os.rename(apkpath, orig_apk)
apkwithnewsig = os.path.join(tmpdir, 'newsig.apk')
with ZipFile(apkpath, 'r') as in_apk:
with ZipFile(apkwithnewsig, 'w') as out_apk:
for sig_file in [signaturefile, signedfile, manifest]:
out_apk.write(sig_file, arcname='META-INF/' +
os.path.basename(sig_file))
for f in in_apk.infolist():
if not apk_sigfile.match(f.filename):
if f.filename != 'META-INF/MANIFEST.MF':
buf = in_apk.read(f.filename)
out_apk.writestr(f.filename, buf)
os.remove(apkpath)
p = SdkToolsPopen(['zipalign', '-v', '4', apkwithnewsig, apkpath])
if p.returncode != 0:
raise BuildException("Failed to align application")
def apk_extract_signatures(apkpath, outdir, manifest=True): def apk_extract_signatures(apkpath, outdir, manifest=True):
"""Extracts a signature files from APK and puts them into target directory. """Extracts a signature files from APK and puts them into target directory.
@ -2061,7 +2273,13 @@ def verify_apks(signed_apk, unsigned_apk, tmp_dir):
describing what went wrong. describing what went wrong.
""" """
signed = ZipFile(signed_apk, 'r') if not os.path.isfile(signed_apk):
return 'can not verify: file does not exists: {}'.format(signed_apk)
if not os.path.isfile(unsigned_apk):
return 'can not verify: file does not exists: {}'.format(unsigned_apk)
with ZipFile(signed_apk, 'r') as signed:
meta_inf_files = ['META-INF/MANIFEST.MF'] meta_inf_files = ['META-INF/MANIFEST.MF']
for f in signed.namelist(): for f in signed.namelist():
if apk_sigfile.match(f) \ if apk_sigfile.match(f) \
@ -2071,20 +2289,19 @@ def verify_apks(signed_apk, unsigned_apk, tmp_dir):
return "Signature files missing from {0}".format(signed_apk) return "Signature files missing from {0}".format(signed_apk)
tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk)) tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
unsigned = ZipFile(unsigned_apk, 'r') with ZipFile(unsigned_apk, 'r') as unsigned:
# only read the signature from the signed APK, everything else from unsigned # only read the signature from the signed APK, everything else from unsigned
with ZipFile(tmp_apk, 'w') as tmp: with ZipFile(tmp_apk, 'w') as tmp:
for filename in meta_inf_files: for filename in meta_inf_files:
tmp.writestr(signed.getinfo(filename), signed.read(filename)) tmp.writestr(signed.getinfo(filename), signed.read(filename))
for info in unsigned.infolist(): for info in unsigned.infolist():
if info.filename in meta_inf_files: if info.filename in meta_inf_files:
logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk) logging.warning('Ignoring %s from %s',
info.filename, unsigned_apk)
continue continue
if info.filename in tmp.namelist(): if info.filename in tmp.namelist():
return "duplicate filename found: " + info.filename return "duplicate filename found: " + info.filename
tmp.writestr(info, unsigned.read(info.filename)) tmp.writestr(info, unsigned.read(info.filename))
unsigned.close()
signed.close()
verified = verify_apk_signature(tmp_apk) verified = verify_apk_signature(tmp_apk)
@ -2348,6 +2565,34 @@ def get_certificate(certificate_file):
return encoder.encode(cert) return encoder.encode(cert)
def load_stats_fdroid_signing_key_fingerprints():
"""Load list of signing-key fingerprints stored by fdroid publish from file.
:returns: list of dictionanryies containing the singing-key fingerprints.
"""
jar_file = os.path.join('stats', 'publishsigkeys.jar')
if not os.path.isfile(jar_file):
return {}
cmd = [config['jarsigner'], '-strict', '-verify', jar_file]
p = FDroidPopen(cmd, output=False)
if p.returncode != 4:
raise FDroidException("Signature validation of '{}' failed! "
"Please run publish again to rebuild this file.".format(jar_file))
jar_sigkey = apk_signer_fingerprint(jar_file)
repo_key_sig = config.get('repo_key_sha256')
if repo_key_sig:
if jar_sigkey != repo_key_sig:
raise FDroidException("Signature key fingerprint of file '{}' does not match repo_key_sha256 in config.py (found fingerprint: '{}')".format(jar_file, jar_sigkey))
else:
logging.warning("repo_key_sha256 not in config.py, setting it to the signature key fingerprint of '{}'".format(jar_file))
config['repo_key_sha256'] = jar_sigkey
write_to_config(config, 'repo_key_sha256')
with zipfile.ZipFile(jar_file, 'r') as f:
return json.loads(str(f.read('publishsigkeys.json'), 'utf-8'))
def write_to_config(thisconfig, key, value=None, config_file=None): def write_to_config(thisconfig, key, value=None, config_file=None):
'''write a key/value to the local config.py '''write a key/value to the local config.py

View File

@ -39,7 +39,7 @@ from . import common
from . import metadata from . import metadata
from . import net from . import net
from . import signindex from . import signindex
from fdroidserver.common import FDroidPopen, FDroidPopenBytes from fdroidserver.common import FDroidPopen, FDroidPopenBytes, load_stats_fdroid_signing_key_fingerprints
from fdroidserver.exception import FDroidException, VerificationException, MetaDataException from fdroidserver.exception import FDroidException, VerificationException, MetaDataException
@ -151,11 +151,15 @@ def make(apps, sortedids, apks, repodir, archive):
raise TypeError(_('only accepts strings, lists, and tuples')) raise TypeError(_('only accepts strings, lists, and tuples'))
requestsdict[command] = packageNames requestsdict[command] = packageNames
make_v0(appsWithPackages, apks, repodir, repodict, requestsdict) fdroid_signing_key_fingerprints = load_stats_fdroid_signing_key_fingerprints()
make_v1(appsWithPackages, apks, repodir, repodict, requestsdict)
make_v0(appsWithPackages, apks, repodir, repodict, requestsdict,
fdroid_signing_key_fingerprints)
make_v1(appsWithPackages, apks, repodir, repodict, requestsdict,
fdroid_signing_key_fingerprints)
def make_v1(apps, packages, repodir, repodict, requestsdict): def make_v1(apps, packages, repodir, repodict, requestsdict, fdroid_signing_key_fingerprints):
def _index_encoder_default(obj): def _index_encoder_default(obj):
if isinstance(obj, set): if isinstance(obj, set):
@ -168,6 +172,9 @@ def make_v1(apps, packages, repodir, repodict, requestsdict):
output['repo'] = repodict output['repo'] = repodict
output['requests'] = requestsdict output['requests'] = requestsdict
# establish sort order of the index
v1_sort_packages(packages, repodir, fdroid_signing_key_fingerprints)
appslist = [] appslist = []
output['apps'] = appslist output['apps'] = appslist
for packageName, appdict in apps.items(): for packageName, appdict in apps.items():
@ -234,7 +241,43 @@ def make_v1(apps, packages, repodir, repodict, requestsdict):
signindex.sign_index_v1(repodir, json_name) signindex.sign_index_v1(repodir, json_name)
def make_v0(apps, apks, repodir, repodict, requestsdict): def v1_sort_packages(packages, repodir, fdroid_signing_key_fingerprints):
"""Sorts the supplied list to ensure a deterministic sort order for
package entries in the index file. This sort-order also expresses
installation preference to the clients.
(First in this list = first to install)
:param packages: list of packages which need to be sorted before but into index file.
"""
GROUP_DEV_SIGNED = 1
GROUP_FDROID_SIGNED = 2
GROUP_OTHER_SIGNED = 3
def v1_sort_keys(package):
packageName = package.get('packageName', None)
sig = package.get('signer', None)
dev_sig = common.metadata_find_developer_signature(packageName)
group = GROUP_OTHER_SIGNED
if dev_sig and dev_sig == sig:
group = GROUP_DEV_SIGNED
else:
fdroidsig = fdroid_signing_key_fingerprints.get(packageName, {}).get('signer')
if fdroidsig and fdroidsig == sig:
group = GROUP_FDROID_SIGNED
versionCode = None
if package.get('versionCode', None):
versionCode = -int(package['versionCode'])
return(packageName, group, sig, versionCode)
packages.sort(key=v1_sort_keys)
def make_v0(apps, apks, repodir, repodict, requestsdict, fdroid_signing_key_fingerprints):
""" """
aka index.jar aka index.xml aka index.jar aka index.xml
""" """
@ -326,12 +369,27 @@ def make_v0(apps, apks, repodir, repodict, requestsdict):
# Get a list of the apks for this app... # Get a list of the apks for this app...
apklist = [] apklist = []
versionCodes = [] apksbyversion = collections.defaultdict(lambda: [])
for apk in apks: for apk in apks:
if apk['packageName'] == appid: if apk.get('versionCode') and apk.get('packageName') == appid:
if apk['versionCode'] not in versionCodes: apksbyversion[apk['versionCode']].append(apk)
apklist.append(apk) for versionCode, apksforver in apksbyversion.items():
versionCodes.append(apk['versionCode']) fdroidsig = fdroid_signing_key_fingerprints.get(appid, {}).get('signer')
fdroid_signed_apk = None
name_match_apk = None
for x in apksforver:
if fdroidsig and x.get('signer', None) == fdroidsig:
fdroid_signed_apk = x
if common.apk_release_filename.match(x.get('apkName', '')):
name_match_apk = x
# choose which of the available versions is most
# suiteable for index v0
if fdroid_signed_apk:
apklist.append(fdroid_signed_apk)
elif name_match_apk:
apklist.append(name_match_apk)
else:
apklist.append(apksforver[0])
if len(apklist) == 0: if len(apklist) == 0:
continue continue

View File

@ -24,19 +24,120 @@ import shutil
import glob import glob
import hashlib import hashlib
from argparse import ArgumentParser from argparse import ArgumentParser
from collections import OrderedDict
import logging import logging
from gettext import ngettext from gettext import ngettext
import json
import zipfile
from . import _ from . import _
from . import common from . import common
from . import metadata from . import metadata
from .common import FDroidPopen, SdkToolsPopen from .common import FDroidPopen, SdkToolsPopen
from .exception import BuildException from .exception import BuildException, FDroidException
config = None config = None
options = None options = None
def publish_source_tarball(apkfilename, unsigned_dir, output_dir):
"""Move the source tarball into the output directory..."""
tarfilename = apkfilename[:-4] + '_src.tar.gz'
tarfile = os.path.join(unsigned_dir, tarfilename)
if os.path.exists(tarfile):
shutil.move(tarfile, os.path.join(output_dir, tarfilename))
logging.debug('...published %s', tarfilename)
else:
logging.debug('...no source tarball for %s', apkfilename)
def key_alias(appid, resolve=False):
"""Get the alias which F-Droid uses to indentify the singing key
for this App in F-Droids keystore.
"""
if config and 'keyaliases' in config and appid in config['keyaliases']:
# For this particular app, the key alias is overridden...
keyalias = config['keyaliases'][appid]
if keyalias.startswith('@'):
m = hashlib.md5()
m.update(keyalias[1:].encode('utf-8'))
keyalias = m.hexdigest()[:8]
return keyalias
else:
m = hashlib.md5()
m.update(appid.encode('utf-8'))
return m.hexdigest()[:8]
def read_fingerprints_from_keystore():
"""Obtain a dictionary containing all singning-key fingerprints which
are managed by F-Droid, grouped by appid.
"""
env_vars = {'LC_ALL': 'C',
'FDROID_KEY_STORE_PASS': config['keystorepass'],
'FDROID_KEY_PASS': config['keypass']}
p = FDroidPopen([config['keytool'], '-list',
'-v', '-keystore', config['keystore'],
'-storepass:env', 'FDROID_KEY_STORE_PASS'],
envs=env_vars, output=False)
if p.returncode != 0:
raise FDroidException('could not read keysotre {}'.format(config['keystore']))
realias = re.compile('Alias name: (?P<alias>.+)\n')
resha256 = re.compile('\s+SHA256: (?P<sha256>[:0-9A-F]{95})\n')
fps = {}
for block in p.output.split(('*' * 43) + '\n' + '*' * 43):
s_alias = realias.search(block)
s_sha256 = resha256.search(block)
if s_alias and s_sha256:
sigfp = s_sha256.group('sha256').replace(':', '').lower()
fps[s_alias.group('alias')] = sigfp
return fps
def sign_sig_key_fingerprint_list(jar_file):
"""sign the list of app-signing key fingerprints which is
used primaryily by fdroid update to determine which APKs
where built and signed by F-Droid and which ones were
manually added by users.
"""
cmd = [config['jarsigner']]
cmd += '-keystore', config['keystore']
cmd += '-storepass:env', 'FDROID_KEY_STORE_PASS'
cmd += '-digestalg', 'SHA1'
cmd += '-sigalg', 'SHA1withRSA'
cmd += jar_file, config['repo_keyalias']
if config['keystore'] == 'NONE':
cmd += config['smartcardoptions']
else: # smardcards never use -keypass
cmd += '-keypass:env', 'FDROID_KEY_PASS'
env_vars = {'FDROID_KEY_STORE_PASS': config['keystorepass'],
'FDROID_KEY_PASS': config['keypass']}
p = common.FDroidPopen(cmd, envs=env_vars)
if p.returncode != 0:
raise FDroidException("Failed to sign '{}'!".format(jar_file))
def store_stats_fdroid_signing_key_fingerprints(appids, indent=None):
"""Store list of all signing-key fingerprints for given appids to HD.
This list will later on be needed by fdroid update.
"""
if not os.path.exists('stats'):
os.makedirs('stats')
data = OrderedDict()
fps = read_fingerprints_from_keystore()
for appid in sorted(appids):
alias = key_alias(appid)
if alias in fps:
data[appid] = {'signer': fps[key_alias(appid)]}
jar_file = os.path.join('stats', 'publishsigkeys.jar')
with zipfile.ZipFile(jar_file, 'w', zipfile.ZIP_DEFLATED) as jar:
jar.writestr('publishsigkeys.json', json.dumps(data, indent=indent))
sign_sig_key_fingerprint_list(jar_file)
def main(): def main():
global config, options global config, options
@ -138,22 +239,54 @@ def main():
if compare_result: if compare_result:
logging.error("...verification failed - publish skipped : " logging.error("...verification failed - publish skipped : "
+ compare_result) + compare_result)
continue else:
# Success! So move the downloaded file to the repo, and remove # Success! So move the downloaded file to the repo, and remove
# our built version. # our built version.
shutil.move(srcapk, os.path.join(output_dir, apkfilename)) shutil.move(srcapk, os.path.join(output_dir, apkfilename))
os.remove(apkfile) os.remove(apkfile)
publish_source_tarball(apkfilename, unsigned_dir, output_dir)
logging.info('Published ' + apkfilename)
elif apkfile.endswith('.zip'): elif apkfile.endswith('.zip'):
# OTA ZIPs built by fdroid do not need to be signed by jarsigner, # OTA ZIPs built by fdroid do not need to be signed by jarsigner,
# just to be moved into place in the repo # just to be moved into place in the repo
shutil.move(apkfile, os.path.join(output_dir, apkfilename)) shutil.move(apkfile, os.path.join(output_dir, apkfilename))
publish_source_tarball(apkfilename, unsigned_dir, output_dir)
logging.info('Published ' + apkfilename)
else: else:
# It's a 'normal' app, i.e. we sign and publish it... # It's a 'normal' app, i.e. we sign and publish it...
skipsigning = False
# First we handle signatures for this app from local metadata
signingfiles = common.metadata_find_developer_signing_files(appid, vercode)
if signingfiles:
# There's a signature of the app developer present in our
# metadata. This means we're going to prepare both a locally
# signed APK and a version signed with the developers key.
signaturefile, signedfile, manifest = signingfiles
with open(signaturefile, 'rb') as f:
devfp = common.signer_fingerprint_short(f.read())
devsigned = '{}_{}_{}.apk'.format(appid, vercode, devfp)
devsignedtmp = os.path.join(tmp_dir, devsigned)
shutil.copy(apkfile, devsignedtmp)
common.apk_implant_signatures(devsignedtmp, signaturefile,
signedfile, manifest)
if common.verify_apk_signature(devsignedtmp):
shutil.move(devsignedtmp, os.path.join(output_dir, devsigned))
else:
os.remove(devsignedtmp)
logging.error('...verification failed - skipping: %s', devsigned)
skipsigning = True
# Now we sign with the F-Droid key.
# Figure out the key alias name we'll use. Only the first 8 # Figure out the key alias name we'll use. Only the first 8
# characters are significant, so we'll use the first 8 from # characters are significant, so we'll use the first 8 from
@ -161,6 +294,7 @@ def main():
# If a collision does occur later, we're going to have to # If a collision does occur later, we're going to have to
# come up with a new alogrithm, AND rename all existing keys # come up with a new alogrithm, AND rename all existing keys
# in the keystore! # in the keystore!
if not skipsigning:
if appid in config['keyaliases']: if appid in config['keyaliases']:
# For this particular app, the key alias is overridden... # For this particular app, the key alias is overridden...
keyalias = config['keyaliases'][appid] keyalias = config['keyaliases'][appid]
@ -194,7 +328,7 @@ def main():
'-keypass:env', 'FDROID_KEY_PASS', '-keypass:env', 'FDROID_KEY_PASS',
'-dname', config['keydname']], envs=env_vars) '-dname', config['keydname']], envs=env_vars)
if p.returncode != 0: if p.returncode != 0:
raise BuildException("Failed to generate key") raise BuildException("Failed to generate key", p.output)
signed_apk_path = os.path.join(output_dir, apkfilename) signed_apk_path = os.path.join(output_dir, apkfilename)
if os.path.exists(signed_apk_path): if os.path.exists(signed_apk_path):
@ -210,7 +344,7 @@ def main():
'SHA1withRSA', '-digestalg', 'SHA1', 'SHA1withRSA', '-digestalg', 'SHA1',
apkfile, keyalias], envs=env_vars) apkfile, keyalias], envs=env_vars)
if p.returncode != 0: if p.returncode != 0:
raise BuildException(_("Failed to sign application")) raise BuildException(_("Failed to sign application"), p.output)
# Zipalign it... # Zipalign it...
p = SdkToolsPopen(['zipalign', '-v', '4', apkfile, p = SdkToolsPopen(['zipalign', '-v', '4', apkfile,
@ -219,14 +353,12 @@ def main():
raise BuildException(_("Failed to align application")) raise BuildException(_("Failed to align application"))
os.remove(apkfile) os.remove(apkfile)
# Move the source tarball into the output directory... publish_source_tarball(apkfilename, unsigned_dir, output_dir)
tarfilename = apkfilename[:-4] + '_src.tar.gz'
tarfile = os.path.join(unsigned_dir, tarfilename)
if os.path.exists(tarfile):
shutil.move(tarfile, os.path.join(output_dir, tarfilename))
logging.info('Published ' + apkfilename) logging.info('Published ' + apkfilename)
store_stats_fdroid_signing_key_fingerprints(allapps.keys())
logging.info('published list signing-key fingerprints')
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -44,7 +44,7 @@ from . import metadata
from .common import SdkToolsPopen from .common import SdkToolsPopen
from .exception import BuildException, FDroidException from .exception import BuildException, FDroidException
METADATA_VERSION = 18 METADATA_VERSION = 19
# less than the valid range of versionCode, i.e. Java's Integer.MIN_VALUE # less than the valid range of versionCode, i.e. Java's Integer.MIN_VALUE
UNSET_VERSION_CODE = -0x100000000 UNSET_VERSION_CODE = -0x100000000
@ -901,7 +901,7 @@ def scan_repo_files(apkcache, repodir, knownapks, use_date_from_file=False):
logging.debug("Reading " + name_utf8 + " from cache") logging.debug("Reading " + name_utf8 + " from cache")
usecache = True usecache = True
else: else:
logging.debug("Ignoring stale cache data for " + name) logging.debug("Ignoring stale cache data for " + name_utf8)
if not usecache: if not usecache:
logging.debug(_("Processing {apkfilename}").format(apkfilename=name_utf8)) logging.debug(_("Processing {apkfilename}").format(apkfilename=name_utf8))
@ -971,11 +971,15 @@ def scan_apk(apk_file):
else: else:
scan_apk_androguard(apk, apk_file) scan_apk_androguard(apk, apk_file)
# Get the signature # Get the signature, or rather the signing key fingerprints
logging.debug('Getting signature of {0}'.format(os.path.basename(apk_file))) logging.debug('Getting signature of {0}'.format(os.path.basename(apk_file)))
apk['sig'] = getsig(apk_file) apk['sig'] = getsig(apk_file)
if not apk['sig']: if not apk['sig']:
raise BuildException("Failed to get apk signature") raise BuildException("Failed to get apk signature")
apk['signer'] = common.apk_signer_fingerprint(os.path.join(os.getcwd(),
apk_file))
if not apk.get('signer'):
raise BuildException("Failed to get apk signing key fingerprint")
# Get size of the APK # Get size of the APK
apk['size'] = os.path.getsize(apk_file) apk['size'] = os.path.getsize(apk_file)

View File

@ -25,6 +25,7 @@ if localmodule not in sys.path:
import fdroidserver.signindex import fdroidserver.signindex
import fdroidserver.common import fdroidserver.common
import fdroidserver.metadata import fdroidserver.metadata
from fdroidserver.exception import FDroidException
class CommonTest(unittest.TestCase): class CommonTest(unittest.TestCase):
@ -376,6 +377,67 @@ class CommonTest(unittest.TestCase):
for name in bad: for name in bad:
self.assertIsNone(fdroidserver.common.STANDARD_FILE_NAME_REGEX.match(name)) self.assertIsNone(fdroidserver.common.STANDARD_FILE_NAME_REGEX.match(name))
def test_apk_signer_fingerprint(self):
# fingerprints fetched with: keytool -printcert -file ____.RSA
testapks = (('repo/obb.main.oldversion_1444412523.apk',
'818e469465f96b704e27be2fee4c63ab9f83ddf30e7a34c7371a4728d83b0bc1'),
('repo/obb.main.twoversions_1101613.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6'),
('repo/obb.main.twoversions_1101617.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6'))
for apkfile, keytoolcertfingerprint in testapks:
self.assertEqual(keytoolcertfingerprint,
fdroidserver.common.apk_signer_fingerprint(apkfile))
def test_apk_signer_fingerprint_short(self):
# fingerprints fetched with: keytool -printcert -file ____.RSA
testapks = (('repo/obb.main.oldversion_1444412523.apk', '818e469'),
('repo/obb.main.twoversions_1101613.apk', '32a2362'),
('repo/obb.main.twoversions_1101617.apk', '32a2362'))
for apkfile, keytoolcertfingerprint in testapks:
self.assertEqual(keytoolcertfingerprint,
fdroidserver.common.apk_signer_fingerprint_short(apkfile))
def test_get_api_id_aapt(self):
config = dict()
fdroidserver.common.fill_config_defaults(config)
fdroidserver.common.config = config
self._set_build_tools()
config['aapt'] = fdroidserver.common.find_sdk_tools_cmd('aapt')
appid, vercode, vername = fdroidserver.common.get_apk_id_aapt('repo/obb.main.twoversions_1101613.apk')
self.assertEqual('obb.main.twoversions', appid)
self.assertEqual('1101613', vercode)
self.assertEqual('0.1', vername)
with self.assertRaises(FDroidException):
fdroidserver.common.get_apk_id_aapt('nope')
def test_apk_release_name(self):
appid, vercode, sigfp = fdroidserver.common.apk_parse_release_filename('com.serwylo.lexica_905.apk')
self.assertEqual(appid, 'com.serwylo.lexica')
self.assertEqual(vercode, '905')
self.assertEqual(sigfp, None)
appid, vercode, sigfp = fdroidserver.common.apk_parse_release_filename('com.serwylo.lexica_905_c82e0f6.apk')
self.assertEqual(appid, 'com.serwylo.lexica')
self.assertEqual(vercode, '905')
self.assertEqual(sigfp, 'c82e0f6')
appid, vercode, sigfp = fdroidserver.common.apk_parse_release_filename('beverly_hills-90210.apk')
self.assertEqual(appid, None)
self.assertEqual(vercode, None)
self.assertEqual(sigfp, None)
def test_metadata_find_developer_signature(self):
sig = fdroidserver.common.metadata_find_developer_signature('org.smssecure.smssecure')
self.assertEqual('b30bb971af0d134866e158ec748fcd553df97c150f58b0a963190bbafbeb0868', sig)
if __name__ == "__main__": if __name__ == "__main__":
parser = optparse.OptionParser() parser = optparse.OptionParser()

BIN
tests/dummy-keystore.jks Normal file

Binary file not shown.

View File

@ -7,8 +7,10 @@ import sys
import unittest import unittest
import zipfile import zipfile
from unittest.mock import patch from unittest.mock import patch
import requests import requests
import tempfile
import json
import shutil
localmodule = os.path.realpath( localmodule = os.path.realpath(
os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '..')) os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '..'))
@ -19,6 +21,8 @@ if localmodule not in sys.path:
import fdroidserver.common import fdroidserver.common
import fdroidserver.index import fdroidserver.index
import fdroidserver.signindex import fdroidserver.signindex
import fdroidserver.publish
from testcommon import TmpCwd
GP_FINGERPRINT = 'B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135' GP_FINGERPRINT = 'B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135'
@ -114,8 +118,117 @@ class IndexTest(unittest.TestCase):
self.assertEqual(10, len(index['packages'])) self.assertEqual(10, len(index['packages']))
self.assertEqual('new_etag', new_etag) self.assertEqual('new_etag', new_etag)
def test_v1_sort_packages(self):
i = [{'packageName': 'org.smssecure.smssecure',
'apkName': 'org.smssecure.smssecure_134.apk',
'signer': 'b33a601a9da97c82e6eb121eb6b90adab561f396602ec4dc8b0019fb587e2af6',
'versionCode': 134},
{'packageName': 'org.smssecure.smssecure',
'apkName': 'org.smssecure.smssecure_134_b30bb97.apk',
'signer': 'b30bb971af0d134866e158ec748fcd553df97c150f58b0a963190bbafbeb0868',
'versionCode': 134},
{'packageName': 'b075b32b4ef1e8a869e00edb136bd48e34a0382b85ced8628f164d1199584e4e'},
{'packageName': '43af70d1aca437c2f9974c4634cc5abe45bdc4d5d71529ac4e553488d3bb3ff6'},
{'packageName': 'org.smssecure.smssecure',
'apkName': 'org.smssecure.smssecure_135_b30bb97.apk',
'signer': 'b30bb971af0d134866e158ec748fcd553df97c150f58b0a963190bbafbeb0868',
'versionCode': 135},
{'packageName': 'org.smssecure.smssecure',
'apkName': 'org.smssecure.smssecure_135.apk',
'signer': 'b33a601a9da97c82e6eb121eb6b90adab561f396602ec4dc8b0019fb587e2af6',
'versionCode': 135},
{'packageName': 'org.smssecure.smssecure',
'apkName': 'org.smssecure.smssecure_133.apk',
'signer': 'b33a601a9da97c82e6eb121eb6b90adab561f396602ec4dc8b0019fb587e2af6',
'versionCode': 133},
{'packageName': 'org.smssecure.smssecure',
'apkName': 'smssecure-weird-version.apk',
'signer': '99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff',
'versionCode': 133},
{'packageName': 'org.smssecure.smssecure',
'apkName': 'smssecure-custom.apk',
'signer': '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'versionCode': 133},
{'packageName': 'org.smssecure.smssecure',
'apkName': 'smssecure-new-custom.apk',
'signer': '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'versionCode': 135}]
o = [{'packageName': '43af70d1aca437c2f9974c4634cc5abe45bdc4d5d71529ac4e553488d3bb3ff6'},
{'packageName': 'b075b32b4ef1e8a869e00edb136bd48e34a0382b85ced8628f164d1199584e4e'},
# app test data
# # packages with reproducible developer signature
{'packageName': 'org.smssecure.smssecure',
'apkName': 'org.smssecure.smssecure_135_b30bb97.apk',
'signer': 'b30bb971af0d134866e158ec748fcd553df97c150f58b0a963190bbafbeb0868',
'versionCode': 135},
{'packageName': 'org.smssecure.smssecure',
'apkName': 'org.smssecure.smssecure_134_b30bb97.apk',
'signer': 'b30bb971af0d134866e158ec748fcd553df97c150f58b0a963190bbafbeb0868',
'versionCode': 134},
# # packages build and signed by fdroid
{'packageName': 'org.smssecure.smssecure',
'apkName': 'org.smssecure.smssecure_135.apk',
'signer': 'b33a601a9da97c82e6eb121eb6b90adab561f396602ec4dc8b0019fb587e2af6',
'versionCode': 135},
{'packageName': 'org.smssecure.smssecure',
'apkName': 'org.smssecure.smssecure_134.apk',
'signer': 'b33a601a9da97c82e6eb121eb6b90adab561f396602ec4dc8b0019fb587e2af6',
'versionCode': 134},
{'packageName': 'org.smssecure.smssecure',
'apkName': 'org.smssecure.smssecure_133.apk',
'signer': 'b33a601a9da97c82e6eb121eb6b90adab561f396602ec4dc8b0019fb587e2af6',
'versionCode': 133},
# # packages signed with unkown keys
{'packageName': 'org.smssecure.smssecure',
'apkName': 'smssecure-new-custom.apk',
'signer': '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'versionCode': 135},
{'packageName': 'org.smssecure.smssecure',
'apkName': 'smssecure-custom.apk',
'signer': '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'versionCode': 133},
{'packageName': 'org.smssecure.smssecure',
'apkName': 'smssecure-weird-version.apk',
'signer': '99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff99ff',
'versionCode': 133}]
fdroidserver.common.config = {}
fdroidserver.common.fill_config_defaults(fdroidserver.common.config)
fdroidserver.publish.config = fdroidserver.common.config
fdroidserver.publish.config['keystorepass'] = '123456'
fdroidserver.publish.config['keypass'] = '123456'
fdroidserver.publish.config['keystore'] = os.path.join(os.getcwd(),
'dummy-keystore.jks')
fdroidserver.publish.config['repo_keyalias'] = 'repokey'
testsmetadir = os.path.join(os.getcwd(), 'metadata')
with tempfile.TemporaryDirectory() as tmpdir, TmpCwd(tmpdir):
shutil.copytree(testsmetadir, 'metadata')
sigkeyfps = {
"org.smssecure.smssecure": {
"signer": "b33a601a9da97c82e6eb121eb6b90adab561f396602ec4dc8b0019fb587e2af6"
}
}
os.makedirs('stats')
jarfile = 'stats/publishsigkeys.jar'
with zipfile.ZipFile(jarfile, 'w', zipfile.ZIP_DEFLATED) as jar:
jar.writestr('publishsigkeys.json', json.dumps(sigkeyfps))
fdroidserver.publish.sign_sig_key_fingerprint_list(jarfile)
with open('config.py', 'w'):
pass
fdroidserver.index.v1_sort_packages(
i, 'repo', fdroidserver.common.load_stats_fdroid_signing_key_fingerprints())
self.maxDiff = None
self.assertEqual(json.dumps(i, indent=2), json.dumps(o, indent=2))
if __name__ == "__main__": if __name__ == "__main__":
if os.path.basename(os.getcwd()) != 'tests' and os.path.isdir('tests'):
os.chdir('tests')
parser = optparse.OptionParser() parser = optparse.OptionParser()
parser.add_option("-v", "--verbose", action="store_true", default=False, parser.add_option("-v", "--verbose", action="store_true", default=False,
help="Spew out even more information than normal") help="Spew out even more information than normal")

View File

@ -12,6 +12,7 @@ icons_src:
minSdkVersion: '4' minSdkVersion: '4'
packageName: info.guardianproject.urzip packageName: info.guardianproject.urzip
sig: e0ecb5fc2d63088e4a07ae410a127722 sig: e0ecb5fc2d63088e4a07ae410a127722
signer: 7eabd8c15de883d1e82b5df2fd4f7f769e498078e9ad6dc901f0e96db77ceac3
size: 9969 size: 9969
targetSdkVersion: '18' targetSdkVersion: '18'
uses-permission: [] uses-permission: []

View File

@ -24,6 +24,7 @@ nativecode:
- x86_64 - x86_64
packageName: org.dyndns.fules.ck packageName: org.dyndns.fules.ck
sig: 9bf7a6a67f95688daec75eab4b1436ac sig: 9bf7a6a67f95688daec75eab4b1436ac
signer: 9326a2cc1a2f148202bc7837a0af3b81200bd37fd359c9e13a2296a71d342056
size: 132453 size: 132453
targetSdkVersion: '8' targetSdkVersion: '8'
uses-permission: uses-permission:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

147
tests/publish.TestCase Executable file
View File

@ -0,0 +1,147 @@
#!/usr/bin/env python3
#
# command which created the keystore used in this test case:
#
# $ for ALIAS in 'repokey a163ec9b d2d51ff2 dc3b169e 78688a0f'; \
# do keytool -genkey -keystore dummy-keystore.jks \
# -alias $ALIAS -keyalg 'RSA' -keysize '2048' \
# -validity '10000' -storepass 123456 \
# -keypass 123456 -dname 'CN=test, OU=F-Droid'; done
#
import inspect
import optparse
import os
import sys
import unittest
import tempfile
import textwrap
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 fdroidserver import publish
from fdroidserver import common
from fdroidserver.exception import FDroidException
class PublishTest(unittest.TestCase):
'''fdroidserver/publish.py'''
def test_key_alias(self):
publish.config = {}
self.assertEqual('a163ec9b', publish.key_alias('com.example.app'))
self.assertEqual('d2d51ff2', publish.key_alias('com.example.anotherapp'))
self.assertEqual('dc3b169e', publish.key_alias('org.test.testy'))
self.assertEqual('78688a0f', publish.key_alias('org.org.org'))
publish.config = {'keyaliases': {'yep.app': '@org.org.org',
'com.example.app': '1a2b3c4d'}}
self.assertEqual('78688a0f', publish.key_alias('yep.app'))
self.assertEqual('1a2b3c4d', publish.key_alias('com.example.app'))
def test_read_fingerprints_from_keystore(self):
common.config = {}
common.fill_config_defaults(common.config)
publish.config = common.config
publish.config['keystorepass'] = '123456'
publish.config['keypass'] = '123456'
publish.config['keystore'] = 'dummy-keystore.jks'
expected = {'78688a0f': '277655a6235bc6b0ef2d824396c51ba947f5ebc738c293d887e7083ff338af82',
'd2d51ff2': 'fa3f6a017541ee7fe797be084b1bcfbf92418a7589ef1f7fdeb46741b6d2e9c3',
'dc3b169e': '6ae5355157a47ddcc3834a71f57f6fb5a8c2621c8e0dc739e9ddf59f865e497c',
'a163ec9b': 'd34f678afbaa8f2fa6cc0edd6f0c2d1d2e2e9eb08bea521b24c740806016bff4',
'repokey': 'c58460800c7b250a619c30c13b07b7359a43e5af71a4352d86c58ae18c9f6d41'}
result = publish.read_fingerprints_from_keystore()
self.maxDiff = None
self.assertEqual(expected, result)
def test_store_and_load_fdroid_signing_key_fingerprints(self):
common.config = {}
common.fill_config_defaults(common.config)
publish.config = common.config
publish.config['keystorepass'] = '123456'
publish.config['keypass'] = '123456'
publish.config['keystore'] = os.path.join(os.getcwd(),
'dummy-keystore.jks')
publish.config['repo_keyalias'] = 'repokey'
appids = ['com.example.app',
'net.unavailable',
'org.test.testy',
'com.example.anotherapp',
'org.org.org']
with tempfile.TemporaryDirectory() as tmpdir:
orig_cwd = os.getcwd()
try:
os.chdir(tmpdir)
with open('config.py', 'w') as f:
pass
publish.store_stats_fdroid_signing_key_fingerprints(appids, indent=2)
self.maxDiff = None
expected = {
"com.example.anotherapp": {
"signer": "fa3f6a017541ee7fe797be084b1bcfbf92418a7589ef1f7fdeb46741b6d2e9c3"
},
"com.example.app": {
"signer": "d34f678afbaa8f2fa6cc0edd6f0c2d1d2e2e9eb08bea521b24c740806016bff4"
},
"org.org.org": {
"signer": "277655a6235bc6b0ef2d824396c51ba947f5ebc738c293d887e7083ff338af82"
},
"org.test.testy": {
"signer": "6ae5355157a47ddcc3834a71f57f6fb5a8c2621c8e0dc739e9ddf59f865e497c"
}
}
self.assertEqual(expected, common.load_stats_fdroid_signing_key_fingerprints())
with open('config.py', 'r') as f:
self.assertEqual(textwrap.dedent('''\
repo_key_sha256 = "c58460800c7b250a619c30c13b07b7359a43e5af71a4352d86c58ae18c9f6d41"
'''), f.read())
finally:
os.chdir(orig_cwd)
def test_store_and_load_fdroid_signing_key_fingerprints_with_missmatch(self):
common.config = {}
common.fill_config_defaults(common.config)
publish.config = common.config
publish.config['keystorepass'] = '123456'
publish.config['keypass'] = '123456'
publish.config['keystore'] = os.path.join(os.getcwd(),
'dummy-keystore.jks')
publish.config['repo_keyalias'] = 'repokey'
publish.config['repo_key_sha256'] = 'bad bad bad bad bad bad bad bad bad bad bad bad'
with tempfile.TemporaryDirectory() as tmpdir:
orig_cwd = os.getcwd()
try:
os.chdir(tmpdir)
publish.store_stats_fdroid_signing_key_fingerprints({}, indent=2)
with self.assertRaises(FDroidException):
common.load_stats_fdroid_signing_key_fingerprints()
finally:
os.chdir(orig_cwd)
if __name__ == "__main__":
if os.path.basename(os.getcwd()) != 'tests' and os.path.isdir('tests'):
os.chdir('tests')
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(PublishTest))
unittest.main()

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<fdroid> <fdroid>
<repo icon="fdroid-icon.png" name="My First F-Droid Repo Demo" pubkey="308204e1308202c9a003020102020434597643300d06092a864886f70d01010b050030213110300e060355040b1307462d44726f6964310d300b06035504031304736f7661301e170d3136303931333230313930395a170d3434303133303230313930395a30213110300e060355040b1307462d44726f6964310d300b06035504031304736f766130820222300d06092a864886f70d01010105000382020f003082020a028202010086ef94b5aacf2ba4f38c875f4194b44f5644392e3715575d7c92828577e692c352b567172823851c8c72347fbc9d99684cd7ca3e1db3e4cca126382c53f2a5869fb4c19bdec989b2930501af3e758ff40588915fe96b10076ce3346a193a0277d79e83e30fd8657c20e35260dd085aa32eac7c4b85786ffefbf1555cafe2bc928443430cdbba48cfbe701e12ae86e676477932730d4fc7c00af820aef85038a5b4df084cf6470d110dc4c49ea1b749b80b34709d199b3db516b223625c5de4501e861f7d261b3838f8f616aa78831d618d41d25872dc810c9b2087b5a9e146ca95be740316dcdbcb77314e23ab87d4487913b800b1113c0603ea2294188b71d3e49875df097b56f9151211fc6832f9790c5c83d17481f14ad37915fd164f4fd713f6732a15f4245714b84cd665bdbd085660ea33ad7d7095dcc414f09e3903604a40facc2314a115c0045bb50e9df38efb57e1b8e7cc105f340a26eeb46aba0fa6672953eee7f1f92dcb408e561909bbd4bdf4a4948c4d57c467d21aa238c34ba43be050398be963191fa2b49828bc1e4eeed224b40dbe9dc3e570890a71a974a2f4527edb1b07105071755105edcb2af2f269facfb89180903a572a99b46456e80d4a01685a80b233278805f2c876678e731f4ec4f52075aeef6b2b023efbb8a3637ef507c4c37c27e428152ec1817fcba640ad601cb09f72f0fbe2d274a2410203010001a321301f301d0603551d0e04160414c28bf33dd5a9a17338e5b1d1a6edd8c7d141ed0b300d06092a864886f70d01010b0500038202010084e20458b2aafd7fc27146b0986f9324f4260f244920417a77c9bf15e2e2d22d2725bdd8093ec261c3779c3ca03312516506f9410075b90595b41345956d8eb2786fb5994f195611382c2b99dba13381b0100a30bc9e6e47248bf4325e2f6eec9d789216dc7536e753bf1f4be603d9fa2e6f5e192b4eb988b8cdb0bb1e8668a9225426f7d4636479f73ed24ad1d2657c31e63c93d9679b9080171b3bd1bf10a3b92b80bd790fbf62d3644900cd08eae8b9bf9c2567be98dc8cdd2ae19a8d57a3e3e2de899f81f1279f578989e6af906f80c8c2b67651730ee7e568c1af5bcb845b6d685dc55332a9984aeceaea3b7e883447edf1c76b155d95253e39b9710eaa22efa6c81468829702b5dce7126538f3ca70c2f0ad9a5795435fdb1f715f20d60359ef9a9926c7050116e802df651727447848827815f70bd82af3cedd08783156102d2d8ce995c4c43b8e47e91a3e6927f3505a5d395e6bebb84542c570903eeab4382a1c2151f1471c7a06a34dc4d268d8fa72e93bdcd2dccc4302ecac47b9e7e3d8bc9b46d21cd097874a24d529548018dc190ff568c6aa428f0a5eedff1a347730931c74f19277538e49647a4ad7254f4c1ec7d4da12cce9e1fad9607534e66ab40a56b473d9d7e3d563fd03cad2052bad365c5a29f8ae54f09b60dbca3ea768d7767cbe1c133ca08ce725c1c1370f4aab8e5b6e286f52dc0be8d0982b5a" timestamp="1480431575" url="https://MyFirstFDroidRepo.org/fdroid/repo" version="18"> <repo icon="fdroid-icon.png" name="My First F-Droid Repo Demo" pubkey="308204e1308202c9a003020102020434597643300d06092a864886f70d01010b050030213110300e060355040b1307462d44726f6964310d300b06035504031304736f7661301e170d3136303931333230313930395a170d3434303133303230313930395a30213110300e060355040b1307462d44726f6964310d300b06035504031304736f766130820222300d06092a864886f70d01010105000382020f003082020a028202010086ef94b5aacf2ba4f38c875f4194b44f5644392e3715575d7c92828577e692c352b567172823851c8c72347fbc9d99684cd7ca3e1db3e4cca126382c53f2a5869fb4c19bdec989b2930501af3e758ff40588915fe96b10076ce3346a193a0277d79e83e30fd8657c20e35260dd085aa32eac7c4b85786ffefbf1555cafe2bc928443430cdbba48cfbe701e12ae86e676477932730d4fc7c00af820aef85038a5b4df084cf6470d110dc4c49ea1b749b80b34709d199b3db516b223625c5de4501e861f7d261b3838f8f616aa78831d618d41d25872dc810c9b2087b5a9e146ca95be740316dcdbcb77314e23ab87d4487913b800b1113c0603ea2294188b71d3e49875df097b56f9151211fc6832f9790c5c83d17481f14ad37915fd164f4fd713f6732a15f4245714b84cd665bdbd085660ea33ad7d7095dcc414f09e3903604a40facc2314a115c0045bb50e9df38efb57e1b8e7cc105f340a26eeb46aba0fa6672953eee7f1f92dcb408e561909bbd4bdf4a4948c4d57c467d21aa238c34ba43be050398be963191fa2b49828bc1e4eeed224b40dbe9dc3e570890a71a974a2f4527edb1b07105071755105edcb2af2f269facfb89180903a572a99b46456e80d4a01685a80b233278805f2c876678e731f4ec4f52075aeef6b2b023efbb8a3637ef507c4c37c27e428152ec1817fcba640ad601cb09f72f0fbe2d274a2410203010001a321301f301d0603551d0e04160414c28bf33dd5a9a17338e5b1d1a6edd8c7d141ed0b300d06092a864886f70d01010b0500038202010084e20458b2aafd7fc27146b0986f9324f4260f244920417a77c9bf15e2e2d22d2725bdd8093ec261c3779c3ca03312516506f9410075b90595b41345956d8eb2786fb5994f195611382c2b99dba13381b0100a30bc9e6e47248bf4325e2f6eec9d789216dc7536e753bf1f4be603d9fa2e6f5e192b4eb988b8cdb0bb1e8668a9225426f7d4636479f73ed24ad1d2657c31e63c93d9679b9080171b3bd1bf10a3b92b80bd790fbf62d3644900cd08eae8b9bf9c2567be98dc8cdd2ae19a8d57a3e3e2de899f81f1279f578989e6af906f80c8c2b67651730ee7e568c1af5bcb845b6d685dc55332a9984aeceaea3b7e883447edf1c76b155d95253e39b9710eaa22efa6c81468829702b5dce7126538f3ca70c2f0ad9a5795435fdb1f715f20d60359ef9a9926c7050116e802df651727447848827815f70bd82af3cedd08783156102d2d8ce995c4c43b8e47e91a3e6927f3505a5d395e6bebb84542c570903eeab4382a1c2151f1471c7a06a34dc4d268d8fa72e93bdcd2dccc4302ecac47b9e7e3d8bc9b46d21cd097874a24d529548018dc190ff568c6aa428f0a5eedff1a347730931c74f19277538e49647a4ad7254f4c1ec7d4da12cce9e1fad9607534e66ab40a56b473d9d7e3d563fd03cad2052bad365c5a29f8ae54f09b60dbca3ea768d7767cbe1c133ca08ce725c1c1370f4aab8e5b6e286f52dc0be8d0982b5a" timestamp="1480431575" url="https://MyFirstFDroidRepo.org/fdroid/repo" version="19">
<description>This is a repository of apps to be used with F-Droid. Applications in this repository are either official binaries built by the original application developers, or are binaries built from source by the admin of f-droid.org using the tools on https://gitlab.com/u/fdroid. </description> <description>This is a repository of apps to be used with F-Droid. Applications in this repository are either official binaries built by the original application developers, or are binaries built from source by the admin of f-droid.org using the tools on https://gitlab.com/u/fdroid. </description>
<mirror>http://foobarfoobarfoobar.onion/fdroid/repo</mirror> <mirror>http://foobarfoobarfoobar.onion/fdroid/repo</mirror>
<mirror>https://foo.bar/fdroid/repo</mirror> <mirror>https://foo.bar/fdroid/repo</mirror>