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
40 from . import metadata
42 from . import signindex
43 from fdroidserver.common import FDroidPopen, FDroidPopenBytes, load_stats_fdroid_signing_key_fingerprints
44 from fdroidserver.exception import FDroidException, VerificationException, 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)
66 if not common.options.nosign:
67 common.assert_config_keystore(common.config)
69 repodict = collections.OrderedDict()
70 repodict['timestamp'] = datetime.utcnow()
71 repodict['version'] = METADATA_VERSION
73 if common.config['repo_maxage'] != 0:
74 repodict['maxage'] = common.config['repo_maxage']
77 repodict['name'] = common.config['archive_name']
78 repodict['icon'] = os.path.basename(common.config['archive_icon'])
79 repodict['address'] = common.config['archive_url']
80 repodict['description'] = common.config['archive_description']
81 urlbasepath = os.path.basename(urllib.parse.urlparse(common.config['archive_url']).path)
83 repodict['name'] = common.config['repo_name']
84 repodict['icon'] = os.path.basename(common.config['repo_icon'])
85 repodict['address'] = common.config['repo_url']
86 repodict['description'] = common.config['repo_description']
87 urlbasepath = os.path.basename(urllib.parse.urlparse(common.config['repo_url']).path)
89 mirrorcheckfailed = False
91 for mirror in sorted(common.config.get('mirrors', [])):
92 base = os.path.basename(urllib.parse.urlparse(mirror).path.rstrip('/'))
93 if common.config.get('nonstandardwebroot') is not True and base != 'fdroid':
94 logging.error(_("mirror '%s' does not end with 'fdroid'!") % mirror)
95 mirrorcheckfailed = True
96 # must end with / or urljoin strips a whole path segment
97 if mirror.endswith('/'):
98 mirrors.append(urllib.parse.urljoin(mirror, urlbasepath))
100 mirrors.append(urllib.parse.urljoin(mirror + '/', urlbasepath))
101 for mirror in common.config.get('servergitmirrors', []):
102 for url in get_mirror_service_urls(mirror):
103 mirrors.append(url + '/' + repodir)
104 if mirrorcheckfailed:
105 raise FDroidException(_("Malformed repository mirrors."))
107 repodict['mirrors'] = mirrors
109 appsWithPackages = collections.OrderedDict()
110 for packageName in sortedids:
111 app = apps[packageName]
115 # only include apps with packages
117 if apk['packageName'] == packageName:
118 newapp = copy.copy(app) # update wiki needs unmodified description
119 newapp['Description'] = metadata.description_html(app['Description'],
120 _resolve_description_link)
121 appsWithPackages[packageName] = newapp
124 requestsdict = collections.OrderedDict()
125 for command in ('install', 'uninstall'):
127 key = command + '_list'
128 if key in common.config:
129 if isinstance(common.config[key], str):
130 packageNames = [common.config[key]]
131 elif all(isinstance(item, str) for item in common.config[key]):
132 packageNames = common.config[key]
134 raise TypeError(_('only accepts strings, lists, and tuples'))
135 requestsdict[command] = packageNames
137 fdroid_signing_key_fingerprints = load_stats_fdroid_signing_key_fingerprints()
139 make_v0(appsWithPackages, apks, repodir, repodict, requestsdict,
140 fdroid_signing_key_fingerprints)
141 make_v1(appsWithPackages, apks, repodir, repodict, requestsdict,
142 fdroid_signing_key_fingerprints)
145 def make_v1(apps, packages, repodir, repodict, requestsdict, fdroid_signing_key_fingerprints):
147 def _index_encoder_default(obj):
148 if isinstance(obj, set):
149 return sorted(list(obj))
150 if isinstance(obj, datetime):
151 # Java prefers milliseconds
152 # we also need to accound for time zone/daylight saving time
153 return int(calendar.timegm(obj.timetuple()) * 1000)
154 if isinstance(obj, dict):
155 d = collections.OrderedDict()
156 for key in sorted(obj.keys()):
159 raise TypeError(repr(obj) + " is not JSON serializable")
161 output = collections.OrderedDict()
162 output['repo'] = repodict
163 output['requests'] = requestsdict
165 # establish sort order of the index
166 v1_sort_packages(packages, repodir, fdroid_signing_key_fingerprints)
169 output['apps'] = appslist
170 for packageName, appdict in apps.items():
171 d = collections.OrderedDict()
173 for k, v in sorted(appdict.items()):
176 if k in ('builds', 'comments', 'metadatapath',
177 'ArchivePolicy', 'AutoUpdateMode', 'MaintainerNotes',
178 'Provides', 'Repo', 'RepoType', 'RequiresRoot',
179 'UpdateCheckData', 'UpdateCheckIgnore', 'UpdateCheckMode',
180 'UpdateCheckName', 'NoSourceSince', 'VercodeOperation'):
183 # name things after the App class fields in fdroidclient
186 elif k == 'CurrentVersionCode': # TODO make SuggestedVersionCode the canonical name
187 k = 'suggestedVersionCode'
188 elif k == 'CurrentVersion': # TODO make SuggestedVersionName the canonical name
189 k = 'suggestedVersionName'
190 elif k == 'AutoName':
191 if 'Name' not in apps[packageName]:
195 k = k[:1].lower() + k[1:]
198 # establish sort order in localized dicts
199 for app in output['apps']:
200 localized = app.get('localized')
202 lordered = collections.OrderedDict()
203 for lkey, lvalue in sorted(localized.items()):
204 lordered[lkey] = collections.OrderedDict()
205 for ikey, iname in sorted(lvalue.items()):
206 lordered[lkey][ikey] = iname
207 app['localized'] = lordered
209 output_packages = collections.OrderedDict()
210 output['packages'] = output_packages
211 for package in packages:
212 packageName = package['packageName']
213 if packageName not in apps:
214 logging.info(_('Ignoring package without metadata: ') + package['apkName'])
216 if packageName in output_packages:
217 packagelist = output_packages[packageName]
220 output_packages[packageName] = packagelist
221 d = collections.OrderedDict()
222 packagelist.append(d)
223 for k, v in sorted(package.items()):
226 if k in ('icon', 'icons', 'icons_src', 'name', ):
230 json_name = 'index-v1.json'
231 index_file = os.path.join(repodir, json_name)
232 with open(index_file, 'w') as fp:
233 if common.options.pretty:
234 json.dump(output, fp, default=_index_encoder_default, indent=2)
236 json.dump(output, fp, default=_index_encoder_default)
238 if common.options.nosign:
239 logging.debug(_('index-v1 must have a signature, use `fdroid signindex` to create it!'))
241 signindex.config = common.config
242 signindex.sign_index_v1(repodir, json_name)
245 def v1_sort_packages(packages, repodir, fdroid_signing_key_fingerprints):
246 """Sorts the supplied list to ensure a deterministic sort order for
247 package entries in the index file. This sort-order also expresses
248 installation preference to the clients.
249 (First in this list = first to install)
251 :param packages: list of packages which need to be sorted before but into index file.
255 GROUP_FDROID_SIGNED = 2
256 GROUP_OTHER_SIGNED = 3
258 def v1_sort_keys(package):
259 packageName = package.get('packageName', None)
261 sig = package.get('signer', None)
263 dev_sig = common.metadata_find_developer_signature(packageName)
264 group = GROUP_OTHER_SIGNED
265 if dev_sig and dev_sig == sig:
266 group = GROUP_DEV_SIGNED
268 fdroidsig = fdroid_signing_key_fingerprints.get(packageName, {}).get('signer')
269 if fdroidsig and fdroidsig == sig:
270 group = GROUP_FDROID_SIGNED
273 if package.get('versionCode', None):
274 versionCode = -int(package['versionCode'])
276 return(packageName, group, sig, versionCode)
278 packages.sort(key=v1_sort_keys)
281 def make_v0(apps, apks, repodir, repodict, requestsdict, fdroid_signing_key_fingerprints):
283 aka index.jar aka index.xml
288 def addElement(name, value, doc, parent):
289 el = doc.createElement(name)
290 el.appendChild(doc.createTextNode(value))
291 parent.appendChild(el)
293 def addElementNonEmpty(name, value, doc, parent):
296 addElement(name, value, doc, parent)
298 def addElementIfInApk(name, apk, key, doc, parent):
301 value = str(apk[key])
302 addElement(name, value, doc, parent)
304 def addElementCDATA(name, value, doc, parent):
305 el = doc.createElement(name)
306 el.appendChild(doc.createCDATASection(value))
307 parent.appendChild(el)
309 def addElementCheckLocalized(name, app, key, doc, parent, default=''):
310 '''Fill in field from metadata or localized block
312 For name/summary/description, they can come only from the app source,
313 or from a dir in fdroiddata. They can be entirely missing from the
314 metadata file if there is localized versions. This will fetch those
315 from the localized version if its not available in the metadata file.
318 el = doc.createElement(name)
320 lkey = key[:1].lower() + key[1:]
321 localized = app.get('localized')
322 if not value and localized:
323 for lang in ['en-US'] + [x for x in localized.keys()]:
324 if not lang.startswith('en'):
326 if lang in localized:
327 value = localized[lang].get(lkey)
330 if not value and localized and len(localized) > 1:
331 lang = list(localized.keys())[0]
332 value = localized[lang].get(lkey)
335 el.appendChild(doc.createTextNode(value))
336 parent.appendChild(el)
338 root = doc.createElement("fdroid")
339 doc.appendChild(root)
341 repoel = doc.createElement("repo")
343 repoel.setAttribute("name", repodict['name'])
344 if 'maxage' in repodict:
345 repoel.setAttribute("maxage", str(repodict['maxage']))
346 repoel.setAttribute("icon", os.path.basename(repodict['icon']))
347 repoel.setAttribute("url", repodict['address'])
348 addElement('description', repodict['description'], doc, repoel)
349 for mirror in repodict.get('mirrors', []):
350 addElement('mirror', mirror, doc, repoel)
352 repoel.setAttribute("version", str(repodict['version']))
353 repoel.setAttribute("timestamp", '%d' % repodict['timestamp'].timestamp())
355 pubkey, repo_pubkey_fingerprint = extract_pubkey()
356 repoel.setAttribute("pubkey", pubkey.decode('utf-8'))
357 root.appendChild(repoel)
359 for command in ('install', 'uninstall'):
360 for packageName in requestsdict[command]:
361 element = doc.createElement(command)
362 root.appendChild(element)
363 element.setAttribute('packageName', packageName)
365 for appid, appdict in apps.items():
366 app = metadata.App(appdict)
368 if app.Disabled is not None:
371 # Get a list of the apks for this app...
373 apksbyversion = collections.defaultdict(lambda: [])
375 if apk.get('versionCode') and apk.get('packageName') == appid:
376 apksbyversion[apk['versionCode']].append(apk)
377 for versionCode, apksforver in apksbyversion.items():
378 fdroidsig = fdroid_signing_key_fingerprints.get(appid, {}).get('signer')
379 fdroid_signed_apk = None
380 name_match_apk = None
382 if fdroidsig and x.get('signer', None) == fdroidsig:
383 fdroid_signed_apk = x
384 if common.apk_release_filename.match(x.get('apkName', '')):
386 # choose which of the available versions is most
387 # suiteable for index v0
388 if fdroid_signed_apk:
389 apklist.append(fdroid_signed_apk)
391 apklist.append(name_match_apk)
393 apklist.append(apksforver[0])
395 if len(apklist) == 0:
398 apel = doc.createElement("application")
399 apel.setAttribute("id", app.id)
400 root.appendChild(apel)
402 addElement('id', app.id, doc, apel)
404 addElement('added', app.added.strftime('%Y-%m-%d'), doc, apel)
406 addElement('lastupdated', app.lastUpdated.strftime('%Y-%m-%d'), doc, apel)
408 addElementCheckLocalized('name', app, 'Name', doc, apel)
409 addElementCheckLocalized('summary', app, 'Summary', doc, apel)
412 addElement('icon', app.icon, doc, apel)
414 addElementCheckLocalized('desc', app, 'Description', doc, apel,
415 '<p>No description available</p>')
417 addElement('license', app.License, doc, apel)
419 addElement('categories', ','.join(app.Categories), doc, apel)
420 # We put the first (primary) category in LAST, which will have
421 # the desired effect of making clients that only understand one
422 # category see that one.
423 addElement('category', app.Categories[0], doc, apel)
424 addElement('web', app.WebSite, doc, apel)
425 addElement('source', app.SourceCode, doc, apel)
426 addElement('tracker', app.IssueTracker, doc, apel)
427 addElementNonEmpty('changelog', app.Changelog, doc, apel)
428 addElementNonEmpty('author', app.AuthorName, doc, apel)
429 addElementNonEmpty('email', app.AuthorEmail, doc, apel)
430 addElementNonEmpty('donate', app.Donate, doc, apel)
431 addElementNonEmpty('bitcoin', app.Bitcoin, doc, apel)
432 addElementNonEmpty('litecoin', app.Litecoin, doc, apel)
433 addElementNonEmpty('flattr', app.FlattrID, doc, apel)
434 addElementNonEmpty('liberapay', app.LiberapayID, doc, apel)
436 # These elements actually refer to the current version (i.e. which
437 # one is recommended. They are historically mis-named, and need
438 # changing, but stay like this for now to support existing clients.
439 addElement('marketversion', app.CurrentVersion, doc, apel)
440 addElement('marketvercode', app.CurrentVersionCode, doc, apel)
443 pv = app.Provides.split(',')
444 addElementNonEmpty('provides', ','.join(pv), doc, apel)
446 addElement('requirements', 'root', doc, apel)
448 # Sort the apk list into version order, just so the web site
449 # doesn't have to do any work by default...
450 apklist = sorted(apklist, key=lambda apk: apk['versionCode'], reverse=True)
452 if 'antiFeatures' in apklist[0]:
453 app.AntiFeatures.extend(apklist[0]['antiFeatures'])
455 addElementNonEmpty('antifeatures', ','.join(app.AntiFeatures), doc, apel)
457 # Check for duplicates - they will make the client unhappy...
458 for i in range(len(apklist) - 1):
460 second = apklist[i + 1]
461 if first['versionCode'] == second['versionCode'] \
462 and first['sig'] == second['sig']:
463 if first['hash'] == second['hash']:
464 raise FDroidException('"{0}/{1}" and "{0}/{2}" are exact duplicates!'.format(
465 repodir, first['apkName'], second['apkName']))
467 raise FDroidException('duplicates: "{0}/{1}" - "{0}/{2}"'.format(
468 repodir, first['apkName'], second['apkName']))
470 current_version_code = 0
471 current_version_file = None
473 file_extension = common.get_file_extension(apk['apkName'])
474 # find the APK for the "Current Version"
475 if current_version_code < apk['versionCode']:
476 current_version_code = apk['versionCode']
477 if current_version_code < int(app.CurrentVersionCode):
478 current_version_file = apk['apkName']
480 apkel = doc.createElement("package")
481 apel.appendChild(apkel)
482 addElement('version', apk['versionName'], doc, apkel)
483 addElement('versioncode', str(apk['versionCode']), doc, apkel)
484 addElement('apkname', apk['apkName'], doc, apkel)
485 addElementIfInApk('srcname', apk, 'srcname', doc, apkel)
487 hashel = doc.createElement("hash")
488 hashel.setAttribute('type', 'sha256')
489 hashel.appendChild(doc.createTextNode(apk['hash']))
490 apkel.appendChild(hashel)
492 addElement('size', str(apk['size']), doc, apkel)
493 addElementIfInApk('sdkver', apk,
494 'minSdkVersion', doc, apkel)
495 addElementIfInApk('targetSdkVersion', apk,
496 'targetSdkVersion', doc, apkel)
497 addElementIfInApk('maxsdkver', apk,
498 'maxSdkVersion', doc, apkel)
499 addElementIfInApk('obbMainFile', apk,
500 'obbMainFile', doc, apkel)
501 addElementIfInApk('obbMainFileSha256', apk,
502 'obbMainFileSha256', doc, apkel)
503 addElementIfInApk('obbPatchFile', apk,
504 'obbPatchFile', doc, apkel)
505 addElementIfInApk('obbPatchFileSha256', apk,
506 'obbPatchFileSha256', doc, apkel)
508 addElement('added', apk['added'].strftime('%Y-%m-%d'), doc, apkel)
510 if file_extension == 'apk': # sig is required for APKs, but only APKs
511 addElement('sig', apk['sig'], doc, apkel)
513 old_permissions = set()
514 sorted_permissions = sorted(apk['uses-permission'])
515 for perm in sorted_permissions:
516 perm_name = perm.name
517 if perm_name.startswith("android.permission."):
518 perm_name = perm_name[19:]
519 old_permissions.add(perm_name)
520 addElementNonEmpty('permissions', ','.join(sorted(old_permissions)), doc, apkel)
522 for permission in sorted_permissions:
523 permel = doc.createElement('uses-permission')
524 permel.setAttribute('name', permission.name)
525 if permission.maxSdkVersion is not None:
526 permel.setAttribute('maxSdkVersion', '%d' % permission.maxSdkVersion)
527 apkel.appendChild(permel)
528 for permission_sdk_23 in sorted(apk['uses-permission-sdk-23']):
529 permel = doc.createElement('uses-permission-sdk-23')
530 permel.setAttribute('name', permission_sdk_23.name)
531 if permission_sdk_23.maxSdkVersion is not None:
532 permel.setAttribute('maxSdkVersion', '%d' % permission_sdk_23.maxSdkVersion)
533 apkel.appendChild(permel)
534 if 'nativecode' in apk:
535 addElement('nativecode', ','.join(sorted(apk['nativecode'])), doc, apkel)
536 addElementNonEmpty('features', ','.join(sorted(apk['features'])), doc, apkel)
538 if current_version_file is not None \
539 and common.config['make_current_version_link'] \
540 and repodir == 'repo': # only create these
541 namefield = common.config['current_version_name_source']
542 sanitized_name = re.sub(b'''[ '"&%?+=/]''', b'', app.get(namefield).encode('utf-8'))
543 apklinkname = sanitized_name + os.path.splitext(current_version_file)[1].encode('utf-8')
544 current_version_path = os.path.join(repodir, current_version_file).encode('utf-8', 'surrogateescape')
545 if os.path.islink(apklinkname):
546 os.remove(apklinkname)
547 os.symlink(current_version_path, apklinkname)
548 # also symlink gpg signature, if it exists
549 for extension in (b'.asc', b'.sig'):
550 sigfile_path = current_version_path + extension
551 if os.path.exists(sigfile_path):
552 siglinkname = apklinkname + extension
553 if os.path.islink(siglinkname):
554 os.remove(siglinkname)
555 os.symlink(sigfile_path, siglinkname)
557 if common.options.pretty:
558 output = doc.toprettyxml(encoding='utf-8')
560 output = doc.toxml(encoding='utf-8')
562 with open(os.path.join(repodir, 'index.xml'), 'wb') as f:
565 if 'repo_keyalias' in common.config:
567 if common.options.nosign:
568 logging.info(_("Creating unsigned index in preparation for signing"))
570 logging.info(_("Creating signed index with this key (SHA256):"))
571 logging.info("%s" % repo_pubkey_fingerprint)
573 # Create a jar of the index...
574 jar_output = 'index_unsigned.jar' if common.options.nosign else 'index.jar'
575 p = FDroidPopen(['jar', 'cf', jar_output, 'index.xml'], cwd=repodir)
576 if p.returncode != 0:
577 raise FDroidException("Failed to create {0}".format(jar_output))
580 signed = os.path.join(repodir, 'index.jar')
581 if common.options.nosign:
582 # Remove old signed index if not signing
583 if os.path.exists(signed):
586 signindex.config = common.config
587 signindex.sign_jar(signed)
589 # Copy the repo icon into the repo directory...
590 icon_dir = os.path.join(repodir, 'icons')
591 iconfilename = os.path.join(icon_dir, os.path.basename(common.config['repo_icon']))
592 shutil.copyfile(common.config['repo_icon'], iconfilename)
595 def extract_pubkey():
597 Extracts and returns the repository's public key from the keystore.
598 :return: public key in hex, repository fingerprint
600 if 'repo_pubkey' in common.config:
601 pubkey = unhexlify(common.config['repo_pubkey'])
603 env_vars = {'FDROID_KEY_STORE_PASS': common.config['keystorepass']}
604 p = FDroidPopenBytes([common.config['keytool'], '-exportcert',
605 '-alias', common.config['repo_keyalias'],
606 '-keystore', common.config['keystore'],
607 '-storepass:env', 'FDROID_KEY_STORE_PASS']
608 + common.config['smartcardoptions'],
609 envs=env_vars, output=False, stderr_to_stdout=False)
610 if p.returncode != 0 or len(p.output) < 20:
611 msg = "Failed to get repo pubkey!"
612 if common.config['keystore'] == 'NONE':
613 msg += ' Is your crypto smartcard plugged in?'
614 raise FDroidException(msg)
616 repo_pubkey_fingerprint = common.get_cert_fingerprint(pubkey)
617 return hexlify(pubkey), repo_pubkey_fingerprint
620 def get_mirror_service_urls(url):
621 '''Get direct URLs from git service for use by fdroidclient
623 Via 'servergitmirrors', fdroidserver can create and push a mirror
624 to certain well known git services like gitlab or github. This
625 will always use the 'master' branch since that is the default
626 branch in git. The files are then accessible via alternate URLs,
627 where they are served in their raw format via a CDN rather than
631 if url.startswith('git@'):
632 url = re.sub(r'^git@(.*):(.*)', r'https://\1/\2', url)
634 segments = url.split("/")
636 if segments[4].endswith('.git'):
637 segments[4] = segments[4][:-4]
639 hostname = segments[2]
646 if hostname == "github.com":
647 # Github-like RAW segments "https://raw.githubusercontent.com/user/repo/branch/folder"
648 segments[2] = "raw.githubusercontent.com"
649 segments.extend([branch, folder])
650 urls.append('/'.join(segments))
651 elif hostname == "gitlab.com":
652 # Both these Gitlab URLs will work with F-Droid, but only the first will work in the browser
653 # This is because the `raw` URLs are not served with the correct mime types, so any
654 # index.html which is put in the repo will not be rendered. Putting an index.html file in
655 # the repo root is a common way for to make information about the repo available to end user.
657 # Gitlab-like Pages segments "https://user.gitlab.io/repo/folder"
658 gitlab_pages = ["https:", "", user + ".gitlab.io", repo, folder]
659 urls.append('/'.join(gitlab_pages))
660 # Gitlab Raw "https://gitlab.com/user/repo/raw/branch/folder"
661 gitlab_raw = segments + ['raw', branch, folder]
662 urls.append('/'.join(gitlab_raw))
668 def download_repo_index(url_str, etag=None, verify_fingerprint=True):
670 Downloads the repository index from the given :param url_str
671 and verifies the repository's fingerprint if :param verify_fingerprint is not False.
673 :raises: VerificationException() if the repository could not be verified
675 :return: A tuple consisting of:
676 - The index in JSON format or None if the index did not change
677 - The new eTag as returned by the HTTP request
679 url = urllib.parse.urlsplit(url_str)
682 if verify_fingerprint:
683 query = urllib.parse.parse_qs(url.query)
684 if 'fingerprint' not in query:
685 raise VerificationException(_("No fingerprint in URL."))
686 fingerprint = query['fingerprint'][0]
688 url = urllib.parse.SplitResult(url.scheme, url.netloc, url.path + '/index-v1.jar', '', '')
689 download, new_etag = net.http_get(url.geturl(), etag)
692 return None, new_etag
694 with tempfile.NamedTemporaryFile() as fp:
695 # write and open JAR file
697 jar = zipfile.ZipFile(fp)
699 # verify that the JAR signature is valid
700 logging.debug(_('Verifying index signature:'))
701 common.verify_jar_signature(fp.name)
703 # get public key and its fingerprint from JAR
704 public_key, public_key_fingerprint = get_public_key_from_jar(jar)
706 # compare the fingerprint if verify_fingerprint is True
707 if verify_fingerprint and fingerprint.upper() != public_key_fingerprint:
708 raise VerificationException(_("The repository's fingerprint does not match."))
710 # load repository index from JSON
711 index = json.loads(jar.read('index-v1.json').decode("utf-8"))
712 index["repo"]["pubkey"] = hexlify(public_key).decode("utf-8")
713 index["repo"]["fingerprint"] = public_key_fingerprint
715 # turn the apps into App objects
716 index["apps"] = [metadata.App(app) for app in index["apps"]]
718 return index, new_etag
721 def get_public_key_from_jar(jar):
723 Get the public key and its fingerprint from a JAR file.
725 :raises: VerificationException() if the JAR was not signed exactly once
727 :param jar: a zipfile.ZipFile object
728 :return: the public key from the jar and its fingerprint
730 # extract certificate from jar
731 certs = [n for n in jar.namelist() if common.CERT_PATH_REGEX.match(n)]
733 raise VerificationException(_("Found no signing certificates for repository."))
735 raise VerificationException(_("Found multiple signing certificates for repository."))
737 # extract public key from certificate
738 public_key = common.get_certificate(jar.read(certs[0]))
739 public_key_fingerprint = common.get_cert_fingerprint(public_key).replace(' ', '')
741 return public_key, public_key_fingerprint