3 # update.py - part of the FDroid server tools
4 # Copyright (C) 2017, Torsten Grote <t at grobox dot de>
5 # Copyright (C) 2016, Blue Jay Wireless
6 # Copyright (C) 2014-2016, Hans-Christoph Steiner <hans@eds.org>
7 # Copyright (C) 2010-2015, Ciaran Gultnieks <ciaran@ciarang.com>
8 # Copyright (C) 2013-2014, Daniel Martà <mvdan@mvdan.cc>
10 # This program is free software: you can redistribute it and/or modify
11 # it under the terms of the GNU Affero General Public License as published by
12 # the Free Software Foundation, either version 3 of the License, or
13 # (at your option) any later version.
15 # This program is distributed in the hope that it will be useful,
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18 # GNU Affero General Public License for more details.
20 # You should have received a copy of the GNU Affero General Public License
21 # along with this program. If not, see <http://www.gnu.org/licenses/>.
34 from binascii import hexlify, unhexlify
35 from datetime import datetime
36 from xml.dom.minidom import Document
39 from pyasn1.codec.der import decoder, encoder
40 from pyasn1_modules import rfc2315
42 from fdroidserver import metadata, signindex, common
43 from fdroidserver.common import FDroidPopen, FDroidPopenBytes
44 from fdroidserver.metadata import MetaDataException
47 def make(apps, sortedids, apks, repodir, archive):
48 """Generate the repo index files.
50 This requires properly initialized options and config objects.
52 :param apps: fully populated apps list
53 :param sortedids: app package IDs, sorted
54 :param apks: full populated apks list
55 :param repodir: the repo directory
56 :param archive: True if this is the archive repo, False if it's the
59 from fdroidserver.update import METADATA_VERSION
61 def _resolve_description_link(appid):
63 return "fdroid.app:" + appid, apps[appid].Name
64 raise MetaDataException("Cannot resolve app id " + appid)
67 if not common.options.nosign:
68 if 'repo_keyalias' not in common.config:
70 logging.critical("'repo_keyalias' not found in config.py!")
71 if 'keystore' not in common.config:
73 logging.critical("'keystore' not found in config.py!")
74 if 'keystorepass' not in common.config and 'keystorepassfile' not in common.config:
76 logging.critical("'keystorepass' not found in config.py!")
77 if 'keypass' not in common.config and 'keypassfile' not in common.config:
79 logging.critical("'keypass' not found in config.py!")
80 if not os.path.exists(common.config['keystore']):
82 logging.critical("'" + common.config['keystore'] + "' does not exist!")
84 logging.warning("`fdroid update` requires a signing key, you can create one using:")
85 logging.warning("\tfdroid update --create-key")
88 repodict = collections.OrderedDict()
89 repodict['timestamp'] = datetime.utcnow()
90 repodict['version'] = METADATA_VERSION
92 if common.config['repo_maxage'] != 0:
93 repodict['maxage'] = common.config['repo_maxage']
96 repodict['name'] = common.config['archive_name']
97 repodict['icon'] = os.path.basename(common.config['archive_icon'])
98 repodict['address'] = common.config['archive_url']
99 repodict['description'] = common.config['archive_description']
100 urlbasepath = os.path.basename(urllib.parse.urlparse(common.config['archive_url']).path)
102 repodict['name'] = common.config['repo_name']
103 repodict['icon'] = os.path.basename(common.config['repo_icon'])
104 repodict['address'] = common.config['repo_url']
105 repodict['description'] = common.config['repo_description']
106 urlbasepath = os.path.basename(urllib.parse.urlparse(common.config['repo_url']).path)
108 mirrorcheckfailed = False
110 for mirror in sorted(common.config.get('mirrors', [])):
111 base = os.path.basename(urllib.parse.urlparse(mirror).path.rstrip('/'))
112 if common.config.get('nonstandardwebroot') is not True and base != 'fdroid':
113 logging.error("mirror '" + mirror + "' does not end with 'fdroid'!")
114 mirrorcheckfailed = True
115 # must end with / or urljoin strips a whole path segment
116 if mirror.endswith('/'):
117 mirrors.append(urllib.parse.urljoin(mirror, urlbasepath))
119 mirrors.append(urllib.parse.urljoin(mirror + '/', urlbasepath))
120 for mirror in common.config.get('servergitmirrors', []):
121 mirror = get_raw_mirror(mirror)
122 if mirror is not None:
123 mirrors.append(mirror + '/')
124 if mirrorcheckfailed:
127 repodict['mirrors'] = mirrors
129 appsWithPackages = collections.OrderedDict()
130 for packageName in sortedids:
131 app = apps[packageName]
135 # only include apps with packages
137 if apk['packageName'] == packageName:
138 newapp = copy.copy(app) # update wiki needs unmodified description
139 newapp['Description'] = metadata.description_html(app['Description'],
140 _resolve_description_link)
141 appsWithPackages[packageName] = newapp
144 requestsdict = dict()
145 for command in ('install', 'uninstall'):
147 key = command + '_list'
148 if key in common.config:
149 if isinstance(common.config[key], str):
150 packageNames = [common.config[key]]
151 elif all(isinstance(item, str) for item in common.config[key]):
152 packageNames = common.config[key]
154 raise TypeError('only accepts strings, lists, and tuples')
155 requestsdict[command] = packageNames
157 make_v0(appsWithPackages, apks, repodir, repodict, requestsdict)
158 make_v1(appsWithPackages, apks, repodir, repodict, requestsdict)
161 def make_v1(apps, packages, repodir, repodict, requestsdict):
163 def _index_encoder_default(obj):
164 if isinstance(obj, set):
166 if isinstance(obj, datetime):
167 return int(obj.timestamp() * 1000) # Java expects milliseconds
168 raise TypeError(repr(obj) + " is not JSON serializable")
170 output = collections.OrderedDict()
171 output['repo'] = repodict
172 output['requests'] = requestsdict
175 output['apps'] = appslist
176 for appid, appdict in apps.items():
177 d = collections.OrderedDict()
179 for k, v in sorted(appdict.items()):
182 if k in ('builds', 'comments', 'metadatapath',
183 'ArchivePolicy', 'AutoUpdateMode', 'MaintainerNotes',
184 'Provides', 'Repo', 'RepoType', 'RequiresRoot',
185 'UpdateCheckData', 'UpdateCheckIgnore', 'UpdateCheckMode',
186 'UpdateCheckName', 'NoSourceSince', 'VercodeOperation'):
189 # name things after the App class fields in fdroidclient
192 elif k == 'CurrentVersionCode': # TODO make SuggestedVersionCode the canonical name
193 k = 'suggestedVersionCode'
194 elif k == 'CurrentVersion': # TODO make SuggestedVersionName the canonical name
195 k = 'suggestedVersionName'
196 elif k == 'AutoName':
197 if 'Name' not in apps[appid]:
201 k = k[:1].lower() + k[1:]
204 output_packages = dict()
205 output['packages'] = output_packages
206 for package in packages:
207 packageName = package['packageName']
208 if packageName in output_packages:
209 packagelist = output_packages[packageName]
212 output_packages[packageName] = packagelist
213 d = collections.OrderedDict()
214 packagelist.append(d)
215 for k, v in sorted(package.items()):
218 if k in ('icon', 'icons', 'icons_src', 'name', ):
222 json_name = 'index-v1.json'
223 index_file = os.path.join(repodir, json_name)
224 with open(index_file, 'w') as fp:
225 json.dump(output, fp, default=_index_encoder_default)
227 if common.options.nosign:
228 logging.debug('index-v1 must have a signature, use `fdroid signindex` to create it!')
230 signindex.config = common.config
231 signindex.sign_index_v1(repodir, json_name)
234 def make_v0(apps, apks, repodir, repodict, requestsdict):
236 aka index.jar aka index.xml
241 def addElement(name, value, doc, parent):
242 el = doc.createElement(name)
243 el.appendChild(doc.createTextNode(value))
244 parent.appendChild(el)
246 def addElementNonEmpty(name, value, doc, parent):
249 addElement(name, value, doc, parent)
251 def addElementIfInApk(name, apk, key, doc, parent):
254 value = str(apk[key])
255 addElement(name, value, doc, parent)
257 def addElementCDATA(name, value, doc, parent):
258 el = doc.createElement(name)
259 el.appendChild(doc.createCDATASection(value))
260 parent.appendChild(el)
262 root = doc.createElement("fdroid")
263 doc.appendChild(root)
265 repoel = doc.createElement("repo")
267 repoel.setAttribute("name", repodict['name'])
268 if 'maxage' in repodict:
269 repoel.setAttribute("maxage", str(repodict['maxage']))
270 repoel.setAttribute("icon", os.path.basename(repodict['icon']))
271 repoel.setAttribute("url", repodict['address'])
272 addElement('description', repodict['description'], doc, repoel)
273 for mirror in repodict.get('mirrors', []):
274 addElement('mirror', mirror, doc, repoel)
276 repoel.setAttribute("version", str(repodict['version']))
277 repoel.setAttribute("timestamp", '%d' % repodict['timestamp'].timestamp())
279 pubkey, repo_pubkey_fingerprint = extract_pubkey()
280 repoel.setAttribute("pubkey", pubkey.decode('utf-8'))
281 root.appendChild(repoel)
283 for command in ('install', 'uninstall'):
284 for packageName in requestsdict[command]:
285 element = doc.createElement(command)
286 root.appendChild(element)
287 element.setAttribute('packageName', packageName)
289 for appid, appdict in apps.items():
290 app = metadata.App(appdict)
292 if app.Disabled is not None:
295 # Get a list of the apks for this app...
298 if apk['packageName'] == appid:
301 if len(apklist) == 0:
304 apel = doc.createElement("application")
305 apel.setAttribute("id", app.id)
306 root.appendChild(apel)
308 addElement('id', app.id, doc, apel)
310 addElement('added', app.added.strftime('%Y-%m-%d'), doc, apel)
312 addElement('lastupdated', app.lastUpdated.strftime('%Y-%m-%d'), doc, apel)
313 addElement('name', app.Name, doc, apel)
314 addElement('summary', app.Summary, doc, apel)
316 addElement('icon', app.icon, doc, apel)
318 if app.get('Description'):
319 description = app.Description
321 description = '<p>No description available</p>'
322 addElement('desc', description, doc, apel)
323 addElement('license', app.License, doc, apel)
325 addElement('categories', ','.join(app.Categories), doc, apel)
326 # We put the first (primary) category in LAST, which will have
327 # the desired effect of making clients that only understand one
328 # category see that one.
329 addElement('category', app.Categories[0], doc, apel)
330 addElement('web', app.WebSite, doc, apel)
331 addElement('source', app.SourceCode, doc, apel)
332 addElement('tracker', app.IssueTracker, doc, apel)
333 addElementNonEmpty('changelog', app.Changelog, doc, apel)
334 addElementNonEmpty('author', app.AuthorName, doc, apel)
335 addElementNonEmpty('email', app.AuthorEmail, doc, apel)
336 addElementNonEmpty('donate', app.Donate, doc, apel)
337 addElementNonEmpty('bitcoin', app.Bitcoin, doc, apel)
338 addElementNonEmpty('litecoin', app.Litecoin, doc, apel)
339 addElementNonEmpty('flattr', app.FlattrID, doc, apel)
341 # These elements actually refer to the current version (i.e. which
342 # one is recommended. They are historically mis-named, and need
343 # changing, but stay like this for now to support existing clients.
344 addElement('marketversion', app.CurrentVersion, doc, apel)
345 addElement('marketvercode', app.CurrentVersionCode, doc, apel)
348 pv = app.Provides.split(',')
349 addElementNonEmpty('provides', ','.join(pv), doc, apel)
351 addElement('requirements', 'root', doc, apel)
353 # Sort the apk list into version order, just so the web site
354 # doesn't have to do any work by default...
355 apklist = sorted(apklist, key=lambda apk: apk['versionCode'], reverse=True)
357 if 'antiFeatures' in apklist[0]:
358 app.AntiFeatures.extend(apklist[0]['antiFeatures'])
360 addElementNonEmpty('antifeatures', ','.join(app.AntiFeatures), doc, apel)
362 # Check for duplicates - they will make the client unhappy...
363 for i in range(len(apklist) - 1):
364 if apklist[i]['versionCode'] == apklist[i + 1]['versionCode']:
365 logging.critical("duplicate versions: '%s' - '%s'" % (
366 apklist[i]['apkName'], apklist[i + 1]['apkName']))
369 current_version_code = 0
370 current_version_file = None
372 file_extension = common.get_file_extension(apk['apkName'])
373 # find the APK for the "Current Version"
374 if current_version_code < apk['versionCode']:
375 current_version_code = apk['versionCode']
376 if current_version_code < int(app.CurrentVersionCode):
377 current_version_file = apk['apkName']
379 apkel = doc.createElement("package")
380 apel.appendChild(apkel)
381 addElement('version', apk['versionName'], doc, apkel)
382 addElement('versioncode', str(apk['versionCode']), doc, apkel)
383 addElement('apkname', apk['apkName'], doc, apkel)
384 addElementIfInApk('srcname', apk, 'srcname', doc, apkel)
386 hashel = doc.createElement("hash")
387 hashel.setAttribute('type', 'sha256')
388 hashel.appendChild(doc.createTextNode(apk['hash']))
389 apkel.appendChild(hashel)
391 addElement('size', str(apk['size']), doc, apkel)
392 addElementIfInApk('sdkver', apk,
393 'minSdkVersion', doc, apkel)
394 addElementIfInApk('targetSdkVersion', apk,
395 'targetSdkVersion', doc, apkel)
396 addElementIfInApk('maxsdkver', apk,
397 'maxSdkVersion', doc, apkel)
398 addElementIfInApk('obbMainFile', apk,
399 'obbMainFile', doc, apkel)
400 addElementIfInApk('obbMainFileSha256', apk,
401 'obbMainFileSha256', doc, apkel)
402 addElementIfInApk('obbPatchFile', apk,
403 'obbPatchFile', doc, apkel)
404 addElementIfInApk('obbPatchFileSha256', apk,
405 'obbPatchFileSha256', doc, apkel)
407 addElement('added', apk['added'].strftime('%Y-%m-%d'), doc, apkel)
409 if file_extension == 'apk': # sig is required for APKs, but only APKs
410 addElement('sig', apk['sig'], doc, apkel)
412 old_permissions = set()
413 sorted_permissions = sorted(apk['uses-permission'])
414 for perm in sorted_permissions:
415 perm_name = perm.name
416 if perm_name.startswith("android.permission."):
417 perm_name = perm_name[19:]
418 old_permissions.add(perm_name)
419 addElementNonEmpty('permissions', ','.join(old_permissions), doc, apkel)
421 for permission in sorted_permissions:
422 permel = doc.createElement('uses-permission')
423 permel.setAttribute('name', permission.name)
424 if permission.maxSdkVersion is not None:
425 permel.setAttribute('maxSdkVersion', '%d' % permission.maxSdkVersion)
426 apkel.appendChild(permel)
427 for permission_sdk_23 in sorted(apk['uses-permission-sdk-23']):
428 permel = doc.createElement('uses-permission-sdk-23')
429 permel.setAttribute('name', permission_sdk_23.name)
430 if permission_sdk_23.maxSdkVersion is not None:
431 permel.setAttribute('maxSdkVersion', '%d' % permission_sdk_23.maxSdkVersion)
432 apkel.appendChild(permel)
433 if 'nativecode' in apk:
434 addElement('nativecode', ','.join(sorted(apk['nativecode'])), doc, apkel)
435 addElementNonEmpty('features', ','.join(sorted(apk['features'])), doc, apkel)
437 if current_version_file is not None \
438 and common.config['make_current_version_link'] \
439 and repodir == 'repo': # only create these
440 namefield = common.config['current_version_name_source']
441 sanitized_name = re.sub('''[ '"&%?+=/]''', '', app.get(namefield))
442 apklinkname = sanitized_name + '.apk'
443 current_version_path = os.path.join(repodir, current_version_file)
444 if os.path.islink(apklinkname):
445 os.remove(apklinkname)
446 os.symlink(current_version_path, apklinkname)
447 # also symlink gpg signature, if it exists
448 for extension in ('.asc', '.sig'):
449 sigfile_path = current_version_path + extension
450 if os.path.exists(sigfile_path):
451 siglinkname = apklinkname + extension
452 if os.path.islink(siglinkname):
453 os.remove(siglinkname)
454 os.symlink(sigfile_path, siglinkname)
456 if common.options.pretty:
457 output = doc.toprettyxml(encoding='utf-8')
459 output = doc.toxml(encoding='utf-8')
461 with open(os.path.join(repodir, 'index.xml'), 'wb') as f:
464 if 'repo_keyalias' in common.config:
466 if common.options.nosign:
467 logging.info("Creating unsigned index in preparation for signing")
469 logging.info("Creating signed index with this key (SHA256):")
470 logging.info("%s" % repo_pubkey_fingerprint)
472 # Create a jar of the index...
473 jar_output = 'index_unsigned.jar' if common.options.nosign else 'index.jar'
474 p = FDroidPopen(['jar', 'cf', jar_output, 'index.xml'], cwd=repodir)
475 if p.returncode != 0:
476 logging.critical("Failed to create {0}".format(jar_output))
480 signed = os.path.join(repodir, 'index.jar')
481 if common.options.nosign:
482 # Remove old signed index if not signing
483 if os.path.exists(signed):
486 signindex.config = common.config
487 signindex.sign_jar(signed)
489 # Copy the repo icon into the repo directory...
490 icon_dir = os.path.join(repodir, 'icons')
491 iconfilename = os.path.join(icon_dir, os.path.basename(common.config['repo_icon']))
492 shutil.copyfile(common.config['repo_icon'], iconfilename)
495 def extract_pubkey():
497 Extracts and returns the repository's public key from the keystore.
498 :return: public key in hex, repository fingerprint
500 if 'repo_pubkey' in common.config:
501 pubkey = unhexlify(common.config['repo_pubkey'])
503 p = FDroidPopenBytes([common.config['keytool'], '-exportcert',
504 '-alias', common.config['repo_keyalias'],
505 '-keystore', common.config['keystore'],
506 '-storepass:file', common.config['keystorepassfile']]
507 + common.config['smartcardoptions'],
508 output=False, stderr_to_stdout=False)
509 if p.returncode != 0 or len(p.output) < 20:
510 msg = "Failed to get repo pubkey!"
511 if common.config['keystore'] == 'NONE':
512 msg += ' Is your crypto smartcard plugged in?'
513 logging.critical(msg)
516 repo_pubkey_fingerprint = common.get_cert_fingerprint(pubkey)
517 return hexlify(pubkey), repo_pubkey_fingerprint
520 # Get raw URL from git service for mirroring
521 def get_raw_mirror(url):
522 # Divide urls in parts
528 # fdroidserver will use always 'master' branch for git-mirroring
532 if hostname == "github.com":
533 # Github like RAW url "https://raw.githubusercontent.com/user/repo/master/fdroid"
534 url[2] = "raw.githubusercontent.com"
535 url.extend([branch, folder])
536 elif hostname == "gitlab.com":
537 # Gitlab like RAW url "https://gitlab.com/user/repo/raw/master/fdroid"
538 url.extend(["raw", branch, folder])
546 class VerificationException(Exception):
550 def download_repo_index(url_str, verify_fingerprint=True):
552 Downloads the repository index from the given :param url_str
553 and verifies the repository's fingerprint if :param verify_fingerprint is not False.
555 :raises: VerificationException() if the repository could not be verified
557 :return: The index in JSON format.
559 url = urllib.parse.urlsplit(url_str)
562 if verify_fingerprint:
563 query = urllib.parse.parse_qs(url.query)
564 if 'fingerprint' not in query:
565 raise VerificationException("No fingerprint in URL.")
566 fingerprint = query['fingerprint'][0]
568 url = urllib.parse.SplitResult(url.scheme, url.netloc, url.path + '/index-v1.jar', '', '')
569 r = requests.get(url.geturl())
571 with tempfile.NamedTemporaryFile() as fp:
572 # write and open JAR file
574 jar = zipfile.ZipFile(fp)
576 # verify that the JAR signature is valid
577 verify_jar_signature(fp.name)
579 # get public key and its fingerprint from JAR
580 public_key, public_key_fingerprint = get_public_key_from_jar(jar)
582 # compare the fingerprint if verify_fingerprint is True
583 if verify_fingerprint and fingerprint.upper() != public_key_fingerprint:
584 raise VerificationException("The repository's fingerprint does not match.")
586 # load repository index from JSON
587 index = json.loads(jar.read('index-v1.json').decode("utf-8"))
588 index["repo"]["pubkey"] = hexlify(public_key).decode("utf-8")
589 index["repo"]["fingerprint"] = public_key_fingerprint
591 # turn the apps into App objects
592 index["apps"] = [metadata.App(app) for app in index["apps"]]
597 def verify_jar_signature(file):
599 Verifies the signature of a given JAR file.
601 :raises: VerificationException() if the JAR's signature could not be verified
603 if not common.verify_apk_signature(file, jar=True):
604 raise VerificationException("The repository's index could not be verified.")
607 def get_public_key_from_jar(jar):
609 Get the public key and its fingerprint from a JAR file.
611 :raises: VerificationException() if the JAR was not signed exactly once
613 :param jar: a zipfile.ZipFile object
614 :return: the public key from the jar and its fingerprint
616 # extract certificate from jar
617 certs = [n for n in jar.namelist() if common.CERT_PATH_REGEX.match(n)]
619 raise VerificationException("Found no signing certificates for repository.")
621 raise VerificationException("Found multiple signing certificates for repository.")
623 # extract public key from certificate
624 public_key = get_public_key_from_certificate(jar.read(certs[0]))
625 public_key_fingerprint = common.get_cert_fingerprint(public_key).replace(' ', '')
627 return public_key, public_key_fingerprint
630 def get_public_key_from_certificate(certificate_file):
632 Extracts a public key from the given certificate.
633 :param certificate_file: file bytes (as string) representing the certificate
634 :return: A binary representation of the certificate's public key
636 content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
637 if content.getComponentByName('contentType') != rfc2315.signedData:
638 raise VerificationException("Unexpected certificate format.")
639 content = decoder.decode(content.getComponentByName('content'),
640 asn1Spec=rfc2315.SignedData())[0]
641 certificates = content.getComponentByName('certificates')
642 cert = certificates[0].getComponentByName('certificate')
643 return encoder.encode(cert)