 Issue 29751598:
  Issue 6291 - Use client certificate for Windows Store uploads  (Closed) 
  Base URL: https://hg.adblockplus.org/abpssembly/file/a67d8f0e66b2
    
  
    Issue 29751598:
  Issue 6291 - Use client certificate for Windows Store uploads  (Closed) 
  Base URL: https://hg.adblockplus.org/abpssembly/file/a67d8f0e66b2| Left: | ||
| Right: | 
| OLD | NEW | 
|---|---|
| 1 # This file is part of the Adblock Plus web scripts, | 1 # This file is part of the Adblock Plus web scripts, | 
| 2 # Copyright (C) 2006-present eyeo GmbH | 2 # Copyright (C) 2006-present eyeo GmbH | 
| 3 # | 3 # | 
| 4 # Adblock Plus is free software: you can redistribute it and/or modify | 4 # Adblock Plus is free software: you can redistribute it and/or modify | 
| 5 # it under the terms of the GNU General Public License version 3 as | 5 # it under the terms of the GNU General Public License version 3 as | 
| 6 # published by the Free Software Foundation. | 6 # published by the Free Software Foundation. | 
| 7 # | 7 # | 
| 8 # Adblock Plus is distributed in the hope that it will be useful, | 8 # Adblock Plus is distributed in the hope that it will be useful, | 
| 9 # but WITHOUT ANY WARRANTY; without even the implied warranty of | 9 # but WITHOUT ANY WARRANTY; without even the implied warranty of | 
| 10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | 10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | 
| 11 # GNU General Public License for more details. | 11 # GNU General Public License for more details. | 
| 12 # | 12 # | 
| 13 # You should have received a copy of the GNU General Public License | 13 # You should have received a copy of the GNU General Public License | 
| 14 # along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>. | 14 # along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>. | 
| 15 | 15 | 
| 16 """ | 16 """ | 
| 17 | 17 | 
| 18 Nightly builds generation script | 18 Nightly builds generation script | 
| 19 ================================ | 19 ================================ | 
| 20 | 20 | 
| 21 This script generates nightly builds of extensions, together | 21 This script generates nightly builds of extensions, together | 
| 22 with changelogs and documentation. | 22 with changelogs and documentation. | 
| 23 | 23 | 
| 24 """ | 24 """ | 
| 25 | 25 | 
| 26 import argparse | 26 import argparse | 
| 27 import ConfigParser | 27 import ConfigParser | 
| 28 import binascii | |
| 28 import base64 | 29 import base64 | 
| 29 import hashlib | 30 import hashlib | 
| 30 import hmac | 31 import hmac | 
| 31 import json | 32 import json | 
| 32 import logging | 33 import logging | 
| 33 import os | 34 import os | 
| 34 import pipes | 35 import pipes | 
| 35 import random | |
| 36 import shutil | 36 import shutil | 
| 37 import struct | 37 import struct | 
| 38 import subprocess | 38 import subprocess | 
| 39 import sys | 39 import sys | 
| 40 import tempfile | 40 import tempfile | 
| 41 import time | 41 import time | 
| 42 import uuid | |
| 42 from urllib import urlencode | 43 from urllib import urlencode | 
| 43 import urllib2 | 44 import urllib2 | 
| 44 import urlparse | 45 import urlparse | 
| 45 import zipfile | 46 import zipfile | 
| 46 import contextlib | 47 import contextlib | 
| 47 | 48 | 
| 49 from Crypto.PublicKey import RSA | |
| 50 from Crypto.Signature import PKCS1_v1_5 | |
| 51 import Crypto.Hash.SHA256 | |
| 52 | |
| 48 from xml.dom.minidom import parse as parseXml | 53 from xml.dom.minidom import parse as parseXml | 
| 
Vasily Kuznetsov
2018/04/17 17:43:54
Nit: this should be above together with other stdl
 
tlucas
2018/04/18 12:58:30
Done.
 | |
| 49 | 54 | 
| 50 from sitescripts.extensions.utils import ( | 55 from sitescripts.extensions.utils import ( | 
| 51 compareVersions, Configuration, | 56 compareVersions, Configuration, | 
| 52 writeAndroidUpdateManifest | 57 writeAndroidUpdateManifest | 
| 53 ) | 58 ) | 
| 54 from sitescripts.utils import get_config, get_template | 59 from sitescripts.utils import get_config, get_template | 
| 55 | 60 | 
| 56 MAX_BUILDS = 50 | 61 MAX_BUILDS = 50 | 
| 57 | 62 | 
| 58 | 63 | 
| (...skipping 388 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 447 try: | 452 try: | 
| 448 for i, entry in enumerate(current[platform]): | 453 for i, entry in enumerate(current[platform]): | 
| 449 if entry[filter_key] == filter_value: | 454 if entry[filter_key] == filter_value: | 
| 450 del current[platform][i] | 455 del current[platform][i] | 
| 451 if len(current[platform]) == 0: | 456 if len(current[platform]) == 0: | 
| 452 del current[platform] | 457 del current[platform] | 
| 453 except KeyError: | 458 except KeyError: | 
| 454 pass | 459 pass | 
| 455 self.write_downloads_lockfile(current) | 460 self.write_downloads_lockfile(current) | 
| 456 | 461 | 
| 457 def generate_jwt_request(self, issuer, secret, url, method, data=None, | 462 def azure_jwt_signature_fnc(self): | 
| 458 add_headers=[]): | 463 return ( | 
| 459 header = { | 464 'RS256', | 
| 460 'alg': 'HS256', # HMAC-SHA256 | 465 lambda s, m: PKCS1_v1_5.new(s).sign(Crypto.Hash.SHA256.new(m)) | 
| 
Vasily Kuznetsov
2018/04/17 17:43:54
Nit: According to the recently landed coding style
 
tlucas
2018/04/18 12:58:30
Done.
 | |
| 461 'typ': 'JWT', | 466 ) | 
| 467 | |
| 468 def mozilla_jwt_signature_fnc(self): | |
| 469 return ( | |
| 470 'HS256', | |
| 471 lambda s, m: hmac.new(s, msg=m, digestmod=hashlib.sha256).digest() | |
| 472 ) | |
| 473 | |
| 474 def sign_jwt(self, issuer, secret, url, signature_fnc, jwt_headers={}): | |
| 475 alg, fnc = signature_fnc() | |
| 476 | |
| 477 header = {'typ': 'JWT'} | |
| 478 header.update(jwt_headers) | |
| 479 header.update({'alg': alg}) | |
| 480 | |
| 481 issued = int(time.time()) | |
| 482 expires = issued + 60 | |
| 483 | |
| 484 payload = { | |
| 485 'aud': url, | |
| 486 'iss': issuer, | |
| 487 'sub': issuer, | |
| 488 'jti': str(uuid.uuid4()), | |
| 489 'iat': issued, | |
| 490 'nbf': issued, | |
| 491 'exp': expires, | |
| 462 } | 492 } | 
| 463 | 493 | 
| 464 issued = int(time.time()) | 494 segments = [base64.urlsafe_b64encode(json.dumps(header)), | 
| 465 payload = { | 495 base64.urlsafe_b64encode(json.dumps(payload))] | 
| 466 'iss': issuer, | |
| 467 'jti': random.random(), | |
| 468 'iat': issued, | |
| 469 'exp': issued + 60, | |
| 470 } | |
| 471 | 496 | 
| 472 hmac_data = '{}.{}'.format( | 497 signature = fnc(secret, b'.'.join(segments)) | 
| 473 base64.b64encode(json.dumps(header)), | 498 segments.append(base64.urlsafe_b64encode(signature)) | 
| 474 base64.b64encode(json.dumps(payload)) | 499 return b'.'.join(segments) | 
| 475 ) | |
| 476 | 500 | 
| 477 signature = hmac.new(secret, msg=hmac_data, | 501 def generate_mozilla_jwt_request(self, issuer, secret, url, method, | 
| 478 digestmod=hashlib.sha256).digest() | 502 data=None, add_headers=[]): | 
| 479 token = '{}.{}'.format(hmac_data, base64.b64encode(signature)) | 503 signed = self.sign_jwt(issuer, secret, url, | 
| 504 self.mozilla_jwt_signature_fnc) | |
| 480 | 505 | 
| 481 request = urllib2.Request(url, data) | 506 request = urllib2.Request(url, data) | 
| 482 request.add_header('Authorization', 'JWT ' + token) | 507 request.add_header('Authorization', 'JWT ' + signed) | 
| 483 for header in add_headers: | 508 for header in add_headers: | 
| 484 request.add_header(*header) | 509 request.add_header(*header) | 
| 485 request.get_method = lambda: method | 510 request.get_method = lambda: method | 
| 486 | 511 | 
| 487 return request | 512 return request | 
| 488 | 513 | 
| 489 def uploadToMozillaAddons(self): | 514 def uploadToMozillaAddons(self): | 
| 490 import urllib3 | 515 import urllib3 | 
| 491 | 516 | 
| 492 config = get_config() | 517 config = get_config() | 
| 493 | 518 | 
| 494 upload_url = ('https://addons.mozilla.org/api/v3/addons/{}/' | 519 upload_url = ('https://addons.mozilla.org/api/v3/addons/{}/' | 
| 495 'versions/{}/').format(self.extensionID, self.version) | 520 'versions/{}/').format(self.extensionID, self.version) | 
| 496 | 521 | 
| 497 with open(self.path, 'rb') as file: | 522 with open(self.path, 'rb') as file: | 
| 498 data, content_type = urllib3.filepost.encode_multipart_formdata({ | 523 data, content_type = urllib3.filepost.encode_multipart_formdata({ | 
| 499 'upload': ( | 524 'upload': ( | 
| 500 os.path.basename(self.path), | 525 os.path.basename(self.path), | 
| 501 file.read(), | 526 file.read(), | 
| 502 'application/x-xpinstall' | 527 'application/x-xpinstall' | 
| 503 ) | 528 ) | 
| 504 }) | 529 }) | 
| 505 | 530 | 
| 506 request = self.generate_jwt_request( | 531 request = self.generate_mozilla_jwt_request( | 
| 507 config.get('extensions', 'amo_key'), | 532 config.get('extensions', 'amo_key'), | 
| 508 config.get('extensions', 'amo_secret'), | 533 config.get('extensions', 'amo_secret'), | 
| 509 upload_url, | 534 upload_url, | 
| 510 'PUT', | 535 'PUT', | 
| 511 data, | 536 data, | 
| 512 [('Content-Type', content_type)] | 537 [('Content-Type', content_type)], | 
| 513 ) | 538 ) | 
| 514 | 539 | 
| 515 try: | 540 try: | 
| 516 urllib2.urlopen(request).close() | 541 urllib2.urlopen(request).close() | 
| 517 except urllib2.HTTPError as e: | 542 except urllib2.HTTPError as e: | 
| 518 try: | 543 try: | 
| 519 logging.error(e.read()) | 544 logging.error(e.read()) | 
| 520 finally: | 545 finally: | 
| 521 e.close() | 546 e.close() | 
| 522 raise | 547 raise | 
| 523 | 548 | 
| 524 self.add_to_downloads_lockfile( | 549 self.add_to_downloads_lockfile( | 
| 525 self.config.type, | 550 self.config.type, | 
| 526 { | 551 { | 
| 527 'buildtype': 'devbuild', | 552 'buildtype': 'devbuild', | 
| 528 'app_id': self.extensionID, | 553 'app_id': self.extensionID, | 
| 529 'version': self.version, | 554 'version': self.version, | 
| 530 } | 555 } | 
| 531 ) | 556 ) | 
| 532 os.remove(self.path) | 557 os.remove(self.path) | 
| 533 | 558 | 
| 534 def download_from_mozilla_addons(self, buildtype, version, app_id): | 559 def download_from_mozilla_addons(self, buildtype, version, app_id): | 
| 535 config = get_config() | 560 config = get_config() | 
| 536 iss = config.get('extensions', 'amo_key') | 561 iss = config.get('extensions', 'amo_key') | 
| 537 secret = config.get('extensions', 'amo_secret') | 562 secret = config.get('extensions', 'amo_secret') | 
| 538 | 563 | 
| 539 url = ('https://addons.mozilla.org/api/v3/addons/{}/' | 564 url = ('https://addons.mozilla.org/api/v3/addons/{}/' | 
| 540 'versions/{}/').format(app_id, version) | 565 'versions/{}/').format(app_id, version) | 
| 541 | 566 | 
| 542 request = self.generate_jwt_request(iss, secret, url, 'GET') | 567 request = self.generate_mozilla_jwt_request( | 
| 568 iss, secret, url, 'GET', | |
| 569 ) | |
| 543 response = json.load(urllib2.urlopen(request)) | 570 response = json.load(urllib2.urlopen(request)) | 
| 544 | 571 | 
| 545 filename = '{}-{}.xpi'.format(self.basename, version) | 572 filename = '{}-{}.xpi'.format(self.basename, version) | 
| 546 self.path = os.path.join( | 573 self.path = os.path.join( | 
| 547 config.get('extensions', 'nightliesDirectory'), | 574 config.get('extensions', 'nightliesDirectory'), | 
| 548 self.basename, | 575 self.basename, | 
| 549 filename | 576 filename | 
| 550 ) | 577 ) | 
| 551 | 578 | 
| 552 necessary = ['passed_review', 'reviewed', 'processed', 'valid'] | 579 necessary = ['passed_review', 'reviewed', 'processed', 'valid'] | 
| 553 if all(response[x] for x in necessary): | 580 if all(response[x] for x in necessary): | 
| 554 download_url = response['files'][0]['download_url'] | 581 download_url = response['files'][0]['download_url'] | 
| 555 checksum = response['files'][0]['hash'] | 582 checksum = response['files'][0]['hash'] | 
| 556 | 583 | 
| 557 request = self.generate_jwt_request(iss, secret, download_url, | 584 request = self.generate_mozilla_jwt_request( | 
| 558 'GET') | 585 iss, secret, download_url, 'GET', | 
| 586 ) | |
| 559 try: | 587 try: | 
| 560 response = urllib2.urlopen(request) | 588 response = urllib2.urlopen(request) | 
| 561 except urllib2.HTTPError as e: | 589 except urllib2.HTTPError as e: | 
| 562 logging.error(e.read()) | 590 logging.error(e.read()) | 
| 563 | 591 | 
| 564 # Verify the extension's integrity | 592 # Verify the extension's integrity | 
| 565 file_content = response.read() | 593 file_content = response.read() | 
| 566 sha256 = hashlib.sha256(file_content) | 594 sha256 = hashlib.sha256(file_content) | 
| 567 returned_checksum = '{}:{}'.format(sha256.name, sha256.hexdigest()) | 595 returned_checksum = '{}:{}'.format(sha256.name, sha256.hexdigest()) | 
| 568 | 596 | 
| (...skipping 70 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 639 request.get_method = lambda: 'POST' | 667 request.get_method = lambda: 'POST' | 
| 640 request.add_header('Authorization', auth_token) | 668 request.add_header('Authorization', auth_token) | 
| 641 request.add_header('x-goog-api-version', '2') | 669 request.add_header('x-goog-api-version', '2') | 
| 642 request.add_header('Content-Length', '0') | 670 request.add_header('Content-Length', '0') | 
| 643 | 671 | 
| 644 response = json.load(opener.open(request)) | 672 response = json.load(opener.open(request)) | 
| 645 | 673 | 
| 646 if any(status not in ('OK', 'ITEM_PENDING_REVIEW') for status in respons e['status']): | 674 if any(status not in ('OK', 'ITEM_PENDING_REVIEW') for status in respons e['status']): | 
| 647 raise Exception({'status': response['status'], 'statusDetail': respo nse['statusDetail']}) | 675 raise Exception({'status': response['status'], 'statusDetail': respo nse['statusDetail']}) | 
| 648 | 676 | 
| 677 def generate_certificate_token_request(self, url, private_key): | |
| 678 # Construct the token request according to | |
| 679 # https://docs.microsoft.com/en-us/azure/active-directory/develop/active -directory-certificate-credentials | |
| 680 hex_val = binascii.a2b_hex(self.config.thumbprint) | |
| 681 x5t = base64.urlsafe_b64encode(hex_val).decode() | |
| 682 | |
| 683 key = RSA.importKey(private_key) | |
| 684 | |
| 685 signed = self.sign_jwt(self.config.clientID, key, url, | |
| 686 self.azure_jwt_signature_fnc, | |
| 687 jwt_headers={'x5t': x5t}) | |
| 688 | |
| 689 # generate oauth parameters for login.microsoft.com | |
| 690 oauth_params = { | |
| 691 'grant_type': 'client_credentials', | |
| 692 'client_id': self.config.clientID, | |
| 693 'resource': 'https://graph.windows.net', | |
| 694 'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-' | |
| 695 'type:jwt-bearer', | |
| 696 'client_assertion': signed, | |
| 697 } | |
| 698 | |
| 699 request = urllib2.Request(url, urlencode(oauth_params)) | |
| 700 request.get_method = lambda: 'POST' | |
| 701 | |
| 702 return request | |
| 703 | |
| 649 def get_windows_store_access_token(self): | 704 def get_windows_store_access_token(self): | 
| 650 # use refresh token to obtain a valid access token | 705 # use client certificate to obtain a valid access token | 
| 651 # https://docs.microsoft.com/en-us/azure/active-directory/active-directo ry-protocols-oauth-code#refreshing-the-access-tokens | 706 url = 'https://login.microsoftonline.com/{}/oauth2/token'.format( | 
| 
Vasily Kuznetsov
2018/04/17 17:43:54
Preexisting version of this was kind of a hack to
 
tlucas
2018/04/18 12:58:30
Done.
 | |
| 652 server = 'https://login.microsoftonline.com' | 707 self.config.tenantID | 
| 653 token_path = '{}/{}/oauth2/token'.format(server, self.config.tenantID) | 708 ) | 
| 709 | |
| 710 with open(self.config.privateKey, 'r') as fp: | |
| 711 private_key = fp.read() | |
| 654 | 712 | 
| 655 opener = urllib2.build_opener(HTTPErrorBodyHandler) | 713 opener = urllib2.build_opener(HTTPErrorBodyHandler) | 
| 656 post_data = urlencode([ | 714 request = self.generate_certificate_token_request(url, private_key) | 
| 657 ('refresh_token', self.config.refreshToken), | 715 | 
| 658 ('client_id', self.config.clientID), | |
| 659 ('client_secret', self.config.clientSecret), | |
| 660 ('grant_type', 'refresh_token'), | |
| 661 ('resource', 'https://graph.windows.net') | |
| 662 ]) | |
| 663 request = urllib2.Request(token_path, post_data) | |
| 664 with contextlib.closing(opener.open(request)) as response: | 716 with contextlib.closing(opener.open(request)) as response: | 
| 665 data = json.load(response) | 717 data = json.load(response) | 
| 666 auth_token = '{0[token_type]} {0[access_token]}'.format(data) | 718 auth_token = '{0[token_type]} {0[access_token]}'.format(data) | 
| 667 | 719 | 
| 668 return auth_token | 720 return auth_token | 
| 669 | 721 | 
| 670 def upload_appx_file_to_windows_store(self, file_upload_url): | 722 def upload_appx_file_to_windows_store(self, file_upload_url): | 
| 671 # Add .appx file to a .zip file | 723 # Add .appx file to a .zip file | 
| 672 zip_path = os.path.splitext(self.path)[0] + '.zip' | 724 zip_path = os.path.splitext(self.path)[0] + '.zip' | 
| 673 with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: | 725 with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: | 
| (...skipping 130 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 804 | 856 | 
| 805 # update nightlies config | 857 # update nightlies config | 
| 806 self.config.latestRevision = self.revision | 858 self.config.latestRevision = self.revision | 
| 807 | 859 | 
| 808 if (self.config.type == 'gecko' and | 860 if (self.config.type == 'gecko' and | 
| 809 self.config.galleryID and | 861 self.config.galleryID and | 
| 810 get_config().has_option('extensions', 'amo_key')): | 862 get_config().has_option('extensions', 'amo_key')): | 
| 811 self.uploadToMozillaAddons() | 863 self.uploadToMozillaAddons() | 
| 812 elif self.config.type == 'chrome' and self.config.clientID and self. config.clientSecret and self.config.refreshToken: | 864 elif self.config.type == 'chrome' and self.config.clientID and self. config.clientSecret and self.config.refreshToken: | 
| 813 self.uploadToChromeWebStore() | 865 self.uploadToChromeWebStore() | 
| 814 elif self.config.type == 'edge' and self.config.clientID and self.co nfig.clientSecret and self.config.refreshToken and self.config.tenantID: | 866 elif self.config.type == 'edge' and self.config.clientID and self.co nfig.tenantID and self.config.privateKey and self.config.thumbprint: | 
| 815 self.upload_to_windows_store() | 867 self.upload_to_windows_store() | 
| 816 | 868 | 
| 817 finally: | 869 finally: | 
| 818 # clean up | 870 # clean up | 
| 819 if self.tempdir: | 871 if self.tempdir: | 
| 820 shutil.rmtree(self.tempdir, ignore_errors=True) | 872 shutil.rmtree(self.tempdir, ignore_errors=True) | 
| 821 | 873 | 
| 822 def download(self): | 874 def download(self): | 
| 823 download_info = self.read_downloads_lockfile() | 875 download_info = self.read_downloads_lockfile() | 
| 824 downloads = self.downloadable_repos.intersection(download_info.keys()) | 876 downloads = self.downloadable_repos.intersection(download_info.keys()) | 
| (...skipping 61 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 886 | 938 | 
| 887 file = open(nightlyConfigFile, 'wb') | 939 file = open(nightlyConfigFile, 'wb') | 
| 888 nightlyConfig.write(file) | 940 nightlyConfig.write(file) | 
| 889 | 941 | 
| 890 | 942 | 
| 891 if __name__ == '__main__': | 943 if __name__ == '__main__': | 
| 892 parser = argparse.ArgumentParser() | 944 parser = argparse.ArgumentParser() | 
| 893 parser.add_argument('--download', action='store_true', default=False) | 945 parser.add_argument('--download', action='store_true', default=False) | 
| 894 args = parser.parse_args() | 946 args = parser.parse_args() | 
| 895 main(args.download) | 947 main(args.download) | 
| OLD | NEW |