Rietveld Code Review Tool
Help | Bug tracker | Discussion group | Source code

Side by Side Diff: sitescripts/extensions/bin/createNightlies.py

Issue 29751598: Issue 6291 - Use client certificate for Windows Store uploads (Closed) Base URL: https://hg.adblockplus.org/abpssembly/file/a67d8f0e66b2
Patch Set: Created April 16, 2018, 2:46 p.m.
Left:
Right:
Use n/p to move between diff chunks; N/P to move between comments.
Jump to:
View unified diff | Download patch
« no previous file with comments | « no previous file | sitescripts/extensions/utils.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
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
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
(...skipping 285 matching lines...) Expand 10 before | Expand all | Expand 10 after
343 if os.path.exists(self.path): 348 if os.path.exists(self.path):
344 os.remove(self.path) 349 os.remove(self.path)
345 raise 350 raise
346 else: 351 else:
347 env = os.environ 352 env = os.environ
348 spiderMonkeyBinary = self.config.spiderMonkeyBinary 353 spiderMonkeyBinary = self.config.spiderMonkeyBinary
349 if spiderMonkeyBinary: 354 if spiderMonkeyBinary:
350 env = dict(env, SPIDERMONKEY_BINARY=spiderMonkeyBinary) 355 env = dict(env, SPIDERMONKEY_BINARY=spiderMonkeyBinary)
351 356
352 command = [os.path.join(self.tempdir, 'build.py')] 357 command = [os.path.join(self.tempdir, 'build.py')]
353 if self.config.type == 'safari': 358 command.extend(['build', '-t', self.config.type, '-b',
354 command.extend(['-t', self.config.type, 'build']) 359 self.buildNum])
Sebastian Noack 2018/04/16 16:13:30 Nit: The indentation is off by one space here.
tlucas 2018/04/16 16:37:44 Done.
355 else:
356 command.extend(['build', '-t', self.config.type])
357 command.extend(['-b', self.buildNum])
358 360
359 if self.config.type not in {'gecko', 'edge'}: 361 if self.config.type not in {'gecko', 'edge'}:
360 command.extend(['-k', self.config.keyFile]) 362 command.extend(['-k', self.config.keyFile])
361 command.append(self.path) 363 command.append(self.path)
362 subprocess.check_call(command, env=env) 364 subprocess.check_call(command, env=env)
363 365
364 if not os.path.exists(self.path): 366 if not os.path.exists(self.path):
365 raise Exception("Build failed, output file hasn't been created") 367 raise Exception("Build failed, output file hasn't been created")
366 368
367 if self.config.type not in self.downloadable_repos: 369 if self.config.type not in self.downloadable_repos:
(...skipping 82 matching lines...) Expand 10 before | Expand all | Expand 10 after
450 try: 452 try:
451 for i, entry in enumerate(current[platform]): 453 for i, entry in enumerate(current[platform]):
452 if entry[filter_key] == filter_value: 454 if entry[filter_key] == filter_value:
453 del current[platform][i] 455 del current[platform][i]
454 if len(current[platform]) == 0: 456 if len(current[platform]) == 0:
455 del current[platform] 457 del current[platform]
456 except KeyError: 458 except KeyError:
457 pass 459 pass
458 self.write_downloads_lockfile(current) 460 self.write_downloads_lockfile(current)
459 461
460 def generate_jwt_request(self, issuer, secret, url, method, data=None, 462 def azure_jwt_signature_fnc(self):
461 add_headers=[]): 463 return ('RS256',
462 header = { 464 lambda s, m: PKCS1_v1_5.new(s).sign(Crypto.Hash.SHA256.new(m)))
463 'alg': 'HS256', # HMAC-SHA256 465
464 'typ': 'JWT', 466 def mozilla_jwt_signature_fnc(self):
467 return (
Sebastian Noack 2018/04/16 16:13:29 Nit: It looks weird to use different flavor of ind
tlucas 2018/04/16 16:37:44 Done.
468 'HS256',
469 lambda s, m: hmac.new(s, msg=m, digestmod=hashlib.sha256).digest()
470 )
471
472 def sign_jwt(self, issuer, secret, url, signature_fnc, jwt_headers={}):
473 alg, fnc = signature_fnc()
474
475 header = {'typ': 'JWT'}
476 header.update(jwt_headers)
477 header.update({'alg': alg})
478
479 issued = int(time.mktime(time.localtime()))
480 expires = issued + 60
481
482 payload = {
483 'aud': url,
484 'iss': issuer,
485 'sub': issuer,
486 'jti': str(uuid.uuid4()),
487 'iat': issued,
488 'nbf': issued,
489 'exp': expires,
465 } 490 }
466 491
467 issued = int(time.time()) 492 segments = [base64.urlsafe_b64encode(json.dumps(header)),
468 payload = { 493 base64.urlsafe_b64encode(json.dumps(payload))]
469 'iss': issuer,
470 'jti': random.random(),
471 'iat': issued,
472 'exp': issued + 60,
473 }
474 494
475 hmac_data = '{}.{}'.format( 495 signature = fnc(secret, b'.'.join(segments))
476 base64.b64encode(json.dumps(header)), 496 segments.append(base64.urlsafe_b64encode(signature))
477 base64.b64encode(json.dumps(payload)) 497 return b'.'.join(segments)
478 )
479 498
480 signature = hmac.new(secret, msg=hmac_data, 499 def generate_mozilla_jwt_request(self, issuer, secret, url, method,
481 digestmod=hashlib.sha256).digest() 500 data=None, add_headers=[]):
482 token = '{}.{}'.format(hmac_data, base64.b64encode(signature)) 501 signed = self.sign_jwt(issuer, secret, url,
502 self.mozilla_jwt_signature_fnc)
483 503
484 request = urllib2.Request(url, data) 504 request = urllib2.Request(url, data)
485 request.add_header('Authorization', 'JWT ' + token) 505 request.add_header('Authorization', 'JWT ' + signed)
486 for header in add_headers: 506 for header in add_headers:
487 request.add_header(*header) 507 request.add_header(*header)
488 request.get_method = lambda: method 508 request.get_method = lambda: method
489 509
490 return request 510 return request
491 511
492 def uploadToMozillaAddons(self): 512 def uploadToMozillaAddons(self):
493 import urllib3 513 import urllib3
494 514
495 config = get_config() 515 config = get_config()
496 516
497 upload_url = ('https://addons.mozilla.org/api/v3/addons/{}/' 517 upload_url = ('https://addons.mozilla.org/api/v3/addons/{}/'
498 'versions/{}/').format(self.extensionID, self.version) 518 'versions/{}/').format(self.extensionID, self.version)
499 519
500 with open(self.path, 'rb') as file: 520 with open(self.path, 'rb') as file:
501 data, content_type = urllib3.filepost.encode_multipart_formdata({ 521 data, content_type = urllib3.filepost.encode_multipart_formdata({
502 'upload': ( 522 'upload': (
503 os.path.basename(self.path), 523 os.path.basename(self.path),
504 file.read(), 524 file.read(),
505 'application/x-xpinstall' 525 'application/x-xpinstall'
506 ) 526 )
507 }) 527 })
508 528
509 request = self.generate_jwt_request( 529 request = self.generate_mozilla_jwt_request(
510 config.get('extensions', 'amo_key'), 530 config.get('extensions', 'amo_key'),
511 config.get('extensions', 'amo_secret'), 531 config.get('extensions', 'amo_secret'),
512 upload_url, 532 upload_url,
513 'PUT', 533 'PUT',
514 data, 534 data,
515 [('Content-Type', content_type)] 535 [('Content-Type', content_type)],
516 ) 536 )
517 537
518 try: 538 try:
519 urllib2.urlopen(request).close() 539 urllib2.urlopen(request).close()
520 except urllib2.HTTPError as e: 540 except urllib2.HTTPError as e:
521 try: 541 try:
522 logging.error(e.read()) 542 logging.error(e.read())
523 finally: 543 finally:
524 e.close() 544 e.close()
525 raise 545 raise
526 546
527 self.add_to_downloads_lockfile( 547 self.add_to_downloads_lockfile(
528 self.config.type, 548 self.config.type,
529 { 549 {
530 'buildtype': 'devbuild', 550 'buildtype': 'devbuild',
531 'app_id': self.extensionID, 551 'app_id': self.extensionID,
532 'version': self.version, 552 'version': self.version,
533 } 553 }
534 ) 554 )
535 os.remove(self.path) 555 os.remove(self.path)
536 556
537 def download_from_mozilla_addons(self, buildtype, version, app_id): 557 def download_from_mozilla_addons(self, buildtype, version, app_id):
538 config = get_config() 558 config = get_config()
539 iss = config.get('extensions', 'amo_key') 559 iss = config.get('extensions', 'amo_key')
540 secret = config.get('extensions', 'amo_secret') 560 secret = config.get('extensions', 'amo_secret')
541 561
542 url = ('https://addons.mozilla.org/api/v3/addons/{}/' 562 url = ('https://addons.mozilla.org/api/v3/addons/{}/'
543 'versions/{}/').format(app_id, version) 563 'versions/{}/').format(app_id, version)
544 564
545 request = self.generate_jwt_request(iss, secret, url, 'GET') 565 request = self.generate_mozilla_jwt_request(
566 iss, secret, url, 'GET',
567 )
546 response = json.load(urllib2.urlopen(request)) 568 response = json.load(urllib2.urlopen(request))
547 569
548 filename = '{}-{}.xpi'.format(self.basename, version) 570 filename = '{}-{}.xpi'.format(self.basename, version)
549 self.path = os.path.join( 571 self.path = os.path.join(
550 config.get('extensions', 'nightliesDirectory'), 572 config.get('extensions', 'nightliesDirectory'),
551 self.basename, 573 self.basename,
552 filename 574 filename
553 ) 575 )
554 576
555 necessary = ['passed_review', 'reviewed', 'processed', 'valid'] 577 necessary = ['passed_review', 'reviewed', 'processed', 'valid']
556 if all(response[x] for x in necessary): 578 if all(response[x] for x in necessary):
557 download_url = response['files'][0]['download_url'] 579 download_url = response['files'][0]['download_url']
558 checksum = response['files'][0]['hash'] 580 checksum = response['files'][0]['hash']
559 581
560 request = self.generate_jwt_request(iss, secret, download_url, 582 request = self.generate_mozilla_jwt_request(
561 'GET') 583 iss, secret, download_url, 'GET',
584 )
562 try: 585 try:
563 response = urllib2.urlopen(request) 586 response = urllib2.urlopen(request)
564 except urllib2.HTTPError as e: 587 except urllib2.HTTPError as e:
565 logging.error(e.read()) 588 logging.error(e.read())
566 589
567 # Verify the extension's integrity 590 # Verify the extension's integrity
568 file_content = response.read() 591 file_content = response.read()
569 sha256 = hashlib.sha256(file_content) 592 sha256 = hashlib.sha256(file_content)
570 returned_checksum = '{}:{}'.format(sha256.name, sha256.hexdigest()) 593 returned_checksum = '{}:{}'.format(sha256.name, sha256.hexdigest())
571 594
(...skipping 70 matching lines...) Expand 10 before | Expand all | Expand 10 after
642 request.get_method = lambda: 'POST' 665 request.get_method = lambda: 'POST'
643 request.add_header('Authorization', auth_token) 666 request.add_header('Authorization', auth_token)
644 request.add_header('x-goog-api-version', '2') 667 request.add_header('x-goog-api-version', '2')
645 request.add_header('Content-Length', '0') 668 request.add_header('Content-Length', '0')
646 669
647 response = json.load(opener.open(request)) 670 response = json.load(opener.open(request))
648 671
649 if any(status not in ('OK', 'ITEM_PENDING_REVIEW') for status in respons e['status']): 672 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']}) 673 raise Exception({'status': response['status'], 'statusDetail': respo nse['statusDetail']})
651 674
675 def generate_certificate_token_request(self, url, private_key):
676 # Construct the token request according to
677 # https://docs.microsoft.com/en-us/azure/active-directory/develop/active -directory-certificate-credentials
678 hex_val = binascii.a2b_hex(self.config.thumbprint)
679 x5t = base64.urlsafe_b64encode(hex_val).decode()
680
Sebastian Noack 2018/04/16 16:13:30 Nit: This blank line looks redundant.
tlucas 2018/04/16 16:37:44 IMHO it doesn't, since the above two lines are not
681 key = RSA.importKey(private_key)
682
683 signed = self.sign_jwt(self.config.clientID, key, url,
684 self.azure_jwt_signature_fnc,
685 jwt_headers={'x5t': x5t})
686
687 # generate oauth parameters for login.microsoft.com
688 oauth_params = {
689 'grant_type': 'client_credentials',
690 'client_id': self.config.clientID,
691 'resource': 'https://graph.windows.net',
692 'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-'
693 'type:jwt-bearer',
694 'client_assertion': signed,
695 }
696
697 request = urllib2.Request(url, urlencode(oauth_params))
698 request.get_method = lambda: 'POST'
699
700 return request
701
652 def get_windows_store_access_token(self): 702 def get_windows_store_access_token(self):
653 # use refresh token to obtain a valid access token 703 # 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 704 url = 'https://login.microsoftonline.com/{}/oauth2/token'.format(
655 server = 'https://login.microsoftonline.com' 705 self.config.tenantID
656 token_path = '{}/{}/oauth2/token'.format(server, self.config.tenantID) 706 )
707
708 with open(self.config.privateKey, 'r') as fp:
709 private_key = fp.read()
657 710
658 opener = urllib2.build_opener(HTTPErrorBodyHandler) 711 opener = urllib2.build_opener(HTTPErrorBodyHandler)
659 post_data = urlencode([ 712 request = self.generate_certificate_token_request(url, private_key)
660 ('refresh_token', self.config.refreshToken), 713
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: 714 with contextlib.closing(opener.open(request)) as response:
668 data = json.load(response) 715 data = json.load(response)
669 auth_token = '{0[token_type]} {0[access_token]}'.format(data) 716 auth_token = '{0[token_type]} {0[access_token]}'.format(data)
670 717
671 return auth_token 718 return auth_token
672 719
673 def upload_appx_file_to_windows_store(self, file_upload_url): 720 def upload_appx_file_to_windows_store(self, file_upload_url):
674 # Add .appx file to a .zip file 721 # Add .appx file to a .zip file
675 zip_path = os.path.splitext(self.path)[0] + '.zip' 722 zip_path = os.path.splitext(self.path)[0] + '.zip'
676 with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: 723 with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
(...skipping 130 matching lines...) Expand 10 before | Expand all | Expand 10 after
807 854
808 # update nightlies config 855 # update nightlies config
809 self.config.latestRevision = self.revision 856 self.config.latestRevision = self.revision
810 857
811 if (self.config.type == 'gecko' and 858 if (self.config.type == 'gecko' and
812 self.config.galleryID and 859 self.config.galleryID and
813 get_config().has_option('extensions', 'amo_key')): 860 get_config().has_option('extensions', 'amo_key')):
814 self.uploadToMozillaAddons() 861 self.uploadToMozillaAddons()
815 elif self.config.type == 'chrome' and self.config.clientID and self. config.clientSecret and self.config.refreshToken: 862 elif self.config.type == 'chrome' and self.config.clientID and self. config.clientSecret and self.config.refreshToken:
816 self.uploadToChromeWebStore() 863 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: 864 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() 865 self.upload_to_windows_store()
819 866
820 finally: 867 finally:
821 # clean up 868 # clean up
822 if self.tempdir: 869 if self.tempdir:
823 shutil.rmtree(self.tempdir, ignore_errors=True) 870 shutil.rmtree(self.tempdir, ignore_errors=True)
824 871
825 def download(self): 872 def download(self):
826 download_info = self.read_downloads_lockfile() 873 download_info = self.read_downloads_lockfile()
827 downloads = self.downloadable_repos.intersection(download_info.keys()) 874 downloads = self.downloadable_repos.intersection(download_info.keys())
(...skipping 61 matching lines...) Expand 10 before | Expand all | Expand 10 after
889 936
890 file = open(nightlyConfigFile, 'wb') 937 file = open(nightlyConfigFile, 'wb')
891 nightlyConfig.write(file) 938 nightlyConfig.write(file)
892 939
893 940
894 if __name__ == '__main__': 941 if __name__ == '__main__':
895 parser = argparse.ArgumentParser() 942 parser = argparse.ArgumentParser()
896 parser.add_argument('--download', action='store_true', default=False) 943 parser.add_argument('--download', action='store_true', default=False)
897 args = parser.parse_args() 944 args = parser.parse_args()
898 main(args.download) 945 main(args.download)
OLDNEW
« no previous file with comments | « no previous file | sitescripts/extensions/utils.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld