 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 | 
| 30 import datetime | |
| 29 import hashlib | 31 import hashlib | 
| 30 import hmac | 32 import hmac | 
| 31 import json | 33 import json | 
| 32 import logging | 34 import logging | 
| 33 import os | 35 import os | 
| 34 import pipes | 36 import pipes | 
| 35 import random | 37 import random | 
| 36 import shutil | 38 import shutil | 
| 37 import struct | 39 import struct | 
| 38 import subprocess | 40 import subprocess | 
| 39 import sys | 41 import sys | 
| 40 import tempfile | 42 import tempfile | 
| 41 import time | 43 import time | 
| 44 import uuid | |
| 42 from urllib import urlencode | 45 from urllib import urlencode | 
| 43 import urllib2 | 46 import urllib2 | 
| 44 import urlparse | 47 import urlparse | 
| 45 import zipfile | 48 import zipfile | 
| 46 import contextlib | 49 import contextlib | 
| 47 | 50 | 
| 51 from Crypto.PublicKey import RSA | |
| 52 from Crypto.Signature import PKCS1_v1_5 | |
| 53 import Crypto.Hash.SHA256 | |
| 54 | |
| 48 from xml.dom.minidom import parse as parseXml | 55 from xml.dom.minidom import parse as parseXml | 
| 49 | 56 | 
| 50 from sitescripts.extensions.utils import ( | 57 from sitescripts.extensions.utils import ( | 
| 51 compareVersions, Configuration, | 58 compareVersions, Configuration, | 
| 52 writeAndroidUpdateManifest | 59 writeAndroidUpdateManifest | 
| 53 ) | 60 ) | 
| 54 from sitescripts.utils import get_config, get_template | 61 from sitescripts.utils import get_config, get_template | 
| 55 | 62 | 
| 56 MAX_BUILDS = 50 | 63 MAX_BUILDS = 50 | 
| 57 | 64 | 
| (...skipping 285 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 343 if os.path.exists(self.path): | 350 if os.path.exists(self.path): | 
| 344 os.remove(self.path) | 351 os.remove(self.path) | 
| 345 raise | 352 raise | 
| 346 else: | 353 else: | 
| 347 env = os.environ | 354 env = os.environ | 
| 348 spiderMonkeyBinary = self.config.spiderMonkeyBinary | 355 spiderMonkeyBinary = self.config.spiderMonkeyBinary | 
| 349 if spiderMonkeyBinary: | 356 if spiderMonkeyBinary: | 
| 350 env = dict(env, SPIDERMONKEY_BINARY=spiderMonkeyBinary) | 357 env = dict(env, SPIDERMONKEY_BINARY=spiderMonkeyBinary) | 
| 351 | 358 | 
| 352 command = [os.path.join(self.tempdir, 'build.py')] | 359 command = [os.path.join(self.tempdir, 'build.py')] | 
| 353 if self.config.type == 'safari': | 360 if self.config.type in {'safari', 'edge'}: | 
| 
tlucas
2018/04/13 13:06:04
The branch 'edge' is built from still uses the old
 
Sebastian Noack
2018/04/14 02:47:30
I would rather see a dependency update landing in
 
tlucas
2018/04/14 08:55:46
Yeah, i agree.
@Ollie - what do you think about th
 
Sebastian Noack
2018/04/14 09:40:54
Alternatively, we could add a hack to build.py:
 
Oleksandr
2018/04/16 04:37:07
I would rather merge `master` into `edge` first. T
 
Sebastian Noack
2018/04/16 05:45:50
Wouldn't this rather be a reason to go with my sug
 
Sebastian Noack
2018/04/16 05:47:19
Sorry, I meant "edge" (not "next").
 
tlucas
2018/04/16 10:06:07
All of the above does IMHO encourage a workaround
 
tlucas
2018/04/16 10:25:14
https://codereview.adblockplus.org/29753557/
https
 
tlucas
2018/04/16 14:50:09
Done.
 | |
| 354 command.extend(['-t', self.config.type, 'build']) | 361 command.extend(['-t', self.config.type, 'build']) | 
| 355 else: | 362 else: | 
| 356 command.extend(['build', '-t', self.config.type]) | 363 command.extend(['build', '-t', self.config.type]) | 
| 357 command.extend(['-b', self.buildNum]) | 364 command.extend(['-b', self.buildNum]) | 
| 358 | 365 | 
| 359 if self.config.type not in {'gecko', 'edge'}: | 366 if self.config.type not in {'gecko', 'edge'}: | 
| 360 command.extend(['-k', self.config.keyFile]) | 367 command.extend(['-k', self.config.keyFile]) | 
| 361 command.append(self.path) | 368 command.append(self.path) | 
| 362 subprocess.check_call(command, env=env) | 369 subprocess.check_call(command, env=env) | 
| 363 | 370 | 
| (...skipping 278 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 642 request.get_method = lambda: 'POST' | 649 request.get_method = lambda: 'POST' | 
| 643 request.add_header('Authorization', auth_token) | 650 request.add_header('Authorization', auth_token) | 
| 644 request.add_header('x-goog-api-version', '2') | 651 request.add_header('x-goog-api-version', '2') | 
| 645 request.add_header('Content-Length', '0') | 652 request.add_header('Content-Length', '0') | 
| 646 | 653 | 
| 647 response = json.load(opener.open(request)) | 654 response = json.load(opener.open(request)) | 
| 648 | 655 | 
| 649 if any(status not in ('OK', 'ITEM_PENDING_REVIEW') for status in respons e['status']): | 656 if any(status not in ('OK', 'ITEM_PENDING_REVIEW') for status in respons e['status']): | 
| 650 raise Exception({'status': response['status'], 'statusDetail': respo nse['statusDetail']}) | 657 raise Exception({'status': response['status'], 'statusDetail': respo nse['statusDetail']}) | 
| 651 | 658 | 
| 659 def generate_certificate_token_request(self, url, private_key): | |
| 660 # Construct the token request according to | |
| 661 # https://docs.microsoft.com/en-us/azure/active-directory/develop/active -directory-certificate-credentials | |
| 662 def base64url_encode(data): | |
| 663 return base64.urlsafe_b64encode(data).replace(b'=', b'') | |
| 
Sebastian Noack
2018/04/14 02:47:30
How about .rstrip(b'=')?
 
tlucas
2018/04/14 08:55:46
see below
 
tlucas
2018/04/16 14:50:09
FWIW, there's no stripping / replacing done anymor
 | |
| 664 | |
| 665 segments = [] | |
| 666 | |
| 667 hex_val = binascii.a2b_hex(self.config.thumbprint) | |
| 668 x5t = base64.urlsafe_b64encode(hex_val).decode() | |
| 669 | |
| 670 now = datetime.datetime.now() | |
| 
Sebastian Noack
2018/04/14 02:47:30
It seems you are relying on the fact that our serv
 
tlucas
2018/04/14 08:55:46
I'm actually thinking about refactoring parts of t
 
tlucas
2018/04/16 14:50:09
Done, almost: mktime() expects a time_struct in lo
 
Sebastian Noack
2018/04/16 16:13:29
You are right. But time.time() gives the same resu
 
tlucas
2018/04/16 16:50:24
Done.
 | |
| 671 minutes = datetime.timedelta(0, 0, 0, 0, 10) | |
| 672 expires = now + minutes | |
| 673 | |
| 674 # generate the full jwt body | |
| 675 jwt_payload = { | |
| 676 'aud': url, | |
| 677 'iss': self.config.clientID, | |
| 678 'sub': self.config.clientID, | |
| 679 'nbf': int(time.mktime(now.timetuple())), | |
| 680 'exp': int(time.mktime(expires.timetuple())), | |
| 681 'jti': str(uuid.uuid4()), | |
| 682 } | |
| 683 | |
| 684 jwt_headers = {'typ': 'JWT', 'alg': 'RS256', 'x5t': x5t} | |
| 685 | |
| 686 # sign the jwt body with the given private key | |
| 687 key = RSA.importKey(private_key) | |
| 688 | |
| 689 segments.append(base64url_encode(json.dumps(jwt_headers))) | |
| 690 segments.append(base64url_encode(json.dumps(jwt_payload))) | |
| 
Sebastian Noack
2018/04/14 02:47:30
Since we don't append to segments above, how about
 
tlucas
2018/04/14 08:55:46
Acknowledged.
 
tlucas
2018/04/16 14:50:09
Done.
 | |
| 691 | |
| 692 body = b'.'.join(segments) | |
| 693 signature = PKCS1_v1_5.new(key).sign(Crypto.Hash.SHA256.new(body)) | |
| 694 | |
| 695 segments.append(base64url_encode(signature)) | |
| 696 signed_jwt = b'.'.join(segments) | |
| 697 | |
| 698 # generate oauth parameters for login.microsoft.com | |
| 699 oauth_params = { | |
| 700 'grant_type': 'client_credentials', | |
| 701 'client_id': self.config.clientID, | |
| 702 'resource': 'https://graph.windows.net', | |
| 703 'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-' | |
| 704 'type:jwt-bearer', | |
| 705 'client_assertion': signed_jwt, | |
| 706 } | |
| 707 | |
| 708 request = urllib2.Request(url, urlencode(oauth_params)) | |
| 709 request.get_method = lambda: 'POST' | |
| 710 | |
| 711 return request | |
| 712 | |
| 652 def get_windows_store_access_token(self): | 713 def get_windows_store_access_token(self): | 
| 653 # use refresh token to obtain a valid access token | 714 # use client certificate to obtain a valid access token | 
| 654 # https://docs.microsoft.com/en-us/azure/active-directory/active-directo ry-protocols-oauth-code#refreshing-the-access-tokens | 715 url = 'https://login.microsoftonline.com/{}/oauth2/token'.format( | 
| 655 server = 'https://login.microsoftonline.com' | 716 self.config.tenantID | 
| 656 token_path = '{}/{}/oauth2/token'.format(server, self.config.tenantID) | 717 ) | 
| 718 | |
| 719 with open(self.config.privateKey, 'r') as fp: | |
| 720 private_key = fp.read() | |
| 657 | 721 | 
| 658 opener = urllib2.build_opener(HTTPErrorBodyHandler) | 722 opener = urllib2.build_opener(HTTPErrorBodyHandler) | 
| 659 post_data = urlencode([ | 723 request = self.generate_certificate_token_request(url, private_key) | 
| 660 ('refresh_token', self.config.refreshToken), | 724 | 
| 661 ('client_id', self.config.clientID), | |
| 662 ('client_secret', self.config.clientSecret), | |
| 663 ('grant_type', 'refresh_token'), | |
| 664 ('resource', 'https://graph.windows.net') | |
| 665 ]) | |
| 666 request = urllib2.Request(token_path, post_data) | |
| 667 with contextlib.closing(opener.open(request)) as response: | 725 with contextlib.closing(opener.open(request)) as response: | 
| 668 data = json.load(response) | 726 data = json.load(response) | 
| 669 auth_token = '{0[token_type]} {0[access_token]}'.format(data) | 727 auth_token = '{0[token_type]} {0[access_token]}'.format(data) | 
| 670 | 728 | 
| 671 return auth_token | 729 return auth_token | 
| 672 | 730 | 
| 673 def upload_appx_file_to_windows_store(self, file_upload_url): | 731 def upload_appx_file_to_windows_store(self, file_upload_url): | 
| 674 # Add .appx file to a .zip file | 732 # Add .appx file to a .zip file | 
| 675 zip_path = os.path.splitext(self.path)[0] + '.zip' | 733 zip_path = os.path.splitext(self.path)[0] + '.zip' | 
| 676 with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: | 734 with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: | 
| (...skipping 130 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 807 | 865 | 
| 808 # update nightlies config | 866 # update nightlies config | 
| 809 self.config.latestRevision = self.revision | 867 self.config.latestRevision = self.revision | 
| 810 | 868 | 
| 811 if (self.config.type == 'gecko' and | 869 if (self.config.type == 'gecko' and | 
| 812 self.config.galleryID and | 870 self.config.galleryID and | 
| 813 get_config().has_option('extensions', 'amo_key')): | 871 get_config().has_option('extensions', 'amo_key')): | 
| 814 self.uploadToMozillaAddons() | 872 self.uploadToMozillaAddons() | 
| 815 elif self.config.type == 'chrome' and self.config.clientID and self. config.clientSecret and self.config.refreshToken: | 873 elif self.config.type == 'chrome' and self.config.clientID and self. config.clientSecret and self.config.refreshToken: | 
| 816 self.uploadToChromeWebStore() | 874 self.uploadToChromeWebStore() | 
| 817 elif self.config.type == 'edge' and self.config.clientID and self.co nfig.clientSecret and self.config.refreshToken and self.config.tenantID: | 875 elif self.config.type == 'edge' and self.config.clientID and self.co nfig.tenantID and self.config.privateKey and self.config.thumbprint: | 
| 818 self.upload_to_windows_store() | 876 self.upload_to_windows_store() | 
| 819 | 877 | 
| 820 finally: | 878 finally: | 
| 821 # clean up | 879 # clean up | 
| 822 if self.tempdir: | 880 if self.tempdir: | 
| 823 shutil.rmtree(self.tempdir, ignore_errors=True) | 881 shutil.rmtree(self.tempdir, ignore_errors=True) | 
| 824 | 882 | 
| 825 def download(self): | 883 def download(self): | 
| 826 download_info = self.read_downloads_lockfile() | 884 download_info = self.read_downloads_lockfile() | 
| 827 downloads = self.downloadable_repos.intersection(download_info.keys()) | 885 downloads = self.downloadable_repos.intersection(download_info.keys()) | 
| (...skipping 61 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 889 | 947 | 
| 890 file = open(nightlyConfigFile, 'wb') | 948 file = open(nightlyConfigFile, 'wb') | 
| 891 nightlyConfig.write(file) | 949 nightlyConfig.write(file) | 
| 892 | 950 | 
| 893 | 951 | 
| 894 if __name__ == '__main__': | 952 if __name__ == '__main__': | 
| 895 parser = argparse.ArgumentParser() | 953 parser = argparse.ArgumentParser() | 
| 896 parser.add_argument('--download', action='store_true', default=False) | 954 parser.add_argument('--download', action='store_true', default=False) | 
| 897 args = parser.parse_args() | 955 args = parser.parse_args() | 
| 898 main(args.download) | 956 main(args.download) | 
| OLD | NEW |