1# Copyright 2016 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5 6import argparse 7import codecs 8import datetime 9import fnmatch 10import glob 11import json 12import os 13import plistlib 14import shutil 15import subprocess 16import stat 17import sys 18import tempfile 19 20if sys.version_info.major < 3: 21 basestring_compat = basestring 22else: 23 basestring_compat = str 24 25 26def GetProvisioningProfilesDir(): 27 """Returns the location of the installed mobile provisioning profiles. 28 29 Returns: 30 The path to the directory containing the installed mobile provisioning 31 profiles as a string. 32 """ 33 return os.path.join( 34 os.environ['HOME'], 'Library', 'MobileDevice', 'Provisioning Profiles') 35 36 37def ReadPlistFromString(plist_bytes): 38 """Parse property list from given |plist_bytes|. 39 40 Args: 41 plist_bytes: contents of property list to load. Must be bytes in python 3. 42 43 Returns: 44 The contents of property list as a python object. 45 """ 46 if sys.version_info.major == 2: 47 return plistlib.readPlistFromString(plist_bytes) 48 else: 49 return plistlib.loads(plist_bytes) 50 51 52def LoadPlistFile(plist_path): 53 """Loads property list file at |plist_path|. 54 55 Args: 56 plist_path: path to the property list file to load. 57 58 Returns: 59 The content of the property list file as a python object. 60 """ 61 if sys.version_info.major == 2: 62 return plistlib.readPlistFromString( 63 subprocess.check_output( 64 ['xcrun', 'plutil', '-convert', 'xml1', '-o', '-', plist_path])) 65 else: 66 with open(plist_path, 'rb') as fp: 67 return plistlib.load(fp) 68 69 70def CreateSymlink(value, location): 71 """Creates symlink with value at location if the target exists.""" 72 target = os.path.join(os.path.dirname(location), value) 73 if os.path.exists(location): 74 os.unlink(location) 75 os.symlink(value, location) 76 77 78class Bundle(object): 79 """Wraps a bundle.""" 80 81 def __init__(self, bundle_path, platform): 82 """Initializes the Bundle object with data from bundle Info.plist file.""" 83 self._path = bundle_path 84 self._kind = Bundle.Kind(platform, os.path.splitext(bundle_path)[-1]) 85 self._data = None 86 87 def Load(self): 88 self._data = LoadPlistFile(self.info_plist_path) 89 90 @staticmethod 91 def Kind(platform, extension): 92 if platform in ('iphoneos', 'iphonesimulator'): 93 return 'ios' 94 if platform == 'macosx': 95 if extension == '.framework': 96 return 'mac_framework' 97 return 'mac' 98 if platform in ('watchos', 'watchsimulator'): 99 return 'watchos' 100 raise ValueError('unknown bundle type %s for %s' % (extension, platform)) 101 102 @property 103 def kind(self): 104 return self._kind 105 106 @property 107 def path(self): 108 return self._path 109 110 @property 111 def contents_dir(self): 112 if self._kind == 'mac': 113 return os.path.join(self.path, 'Contents') 114 if self._kind == 'mac_framework': 115 return os.path.join(self.path, 'Versions/A') 116 return self.path 117 118 @property 119 def executable_dir(self): 120 if self._kind == 'mac': 121 return os.path.join(self.contents_dir, 'MacOS') 122 return self.contents_dir 123 124 @property 125 def resources_dir(self): 126 if self._kind == 'mac' or self._kind == 'mac_framework': 127 return os.path.join(self.contents_dir, 'Resources') 128 return self.path 129 130 @property 131 def info_plist_path(self): 132 if self._kind == 'mac_framework': 133 return os.path.join(self.resources_dir, 'Info.plist') 134 return os.path.join(self.contents_dir, 'Info.plist') 135 136 @property 137 def signature_dir(self): 138 return os.path.join(self.contents_dir, '_CodeSignature') 139 140 @property 141 def identifier(self): 142 return self._data['CFBundleIdentifier'] 143 144 @property 145 def binary_name(self): 146 return self._data['CFBundleExecutable'] 147 148 @property 149 def binary_path(self): 150 return os.path.join(self.executable_dir, self.binary_name) 151 152 def Validate(self, expected_mappings): 153 """Checks that keys in the bundle have the expected value. 154 155 Args: 156 expected_mappings: a dictionary of string to object, each mapping will 157 be looked up in the bundle data to check it has the same value (missing 158 values will be ignored) 159 160 Returns: 161 A dictionary of the key with a different value between expected_mappings 162 and the content of the bundle (i.e. errors) so that caller can format the 163 error message. The dictionary will be empty if there are no errors. 164 """ 165 errors = {} 166 for key, expected_value in expected_mappings.items(): 167 if key in self._data: 168 value = self._data[key] 169 if value != expected_value: 170 errors[key] = (value, expected_value) 171 return errors 172 173 174class ProvisioningProfile(object): 175 """Wraps a mobile provisioning profile file.""" 176 177 def __init__(self, provisioning_profile_path): 178 """Initializes the ProvisioningProfile with data from profile file.""" 179 self._path = provisioning_profile_path 180 self._data = ReadPlistFromString( 181 subprocess.check_output([ 182 'xcrun', 'security', 'cms', '-D', '-u', 'certUsageAnyCA', '-i', 183 provisioning_profile_path 184 ])) 185 186 @property 187 def path(self): 188 return self._path 189 190 @property 191 def team_identifier(self): 192 return self._data.get('TeamIdentifier', [''])[0] 193 194 @property 195 def name(self): 196 return self._data.get('Name', '') 197 198 @property 199 def application_identifier_pattern(self): 200 return self._data.get('Entitlements', {}).get('application-identifier', '') 201 202 @property 203 def application_identifier_prefix(self): 204 return self._data.get('ApplicationIdentifierPrefix', [''])[0] 205 206 @property 207 def entitlements(self): 208 return self._data.get('Entitlements', {}) 209 210 @property 211 def expiration_date(self): 212 return self._data.get('ExpirationDate', datetime.datetime.now()) 213 214 def ValidToSignBundle(self, bundle_identifier): 215 """Checks whether the provisioning profile can sign bundle_identifier. 216 217 Args: 218 bundle_identifier: the identifier of the bundle that needs to be signed. 219 220 Returns: 221 True if the mobile provisioning profile can be used to sign a bundle 222 with the corresponding bundle_identifier, False otherwise. 223 """ 224 return fnmatch.fnmatch( 225 '%s.%s' % (self.application_identifier_prefix, bundle_identifier), 226 self.application_identifier_pattern) 227 228 def Install(self, installation_path): 229 """Copies mobile provisioning profile info to |installation_path|.""" 230 shutil.copy2(self.path, installation_path) 231 st = os.stat(installation_path) 232 os.chmod(installation_path, st.st_mode | stat.S_IWUSR) 233 234 235class Entitlements(object): 236 """Wraps an Entitlement plist file.""" 237 238 def __init__(self, entitlements_path): 239 """Initializes Entitlements object from entitlement file.""" 240 self._path = entitlements_path 241 self._data = LoadPlistFile(self._path) 242 243 @property 244 def path(self): 245 return self._path 246 247 def ExpandVariables(self, substitutions): 248 self._data = self._ExpandVariables(self._data, substitutions) 249 250 def _ExpandVariables(self, data, substitutions): 251 if isinstance(data, basestring_compat): 252 for key, substitution in substitutions.items(): 253 data = data.replace('$(%s)' % (key,), substitution) 254 return data 255 256 if isinstance(data, dict): 257 for key, value in data.items(): 258 data[key] = self._ExpandVariables(value, substitutions) 259 return data 260 261 if isinstance(data, list): 262 for i, value in enumerate(data): 263 data[i] = self._ExpandVariables(value, substitutions) 264 265 return data 266 267 def LoadDefaults(self, defaults): 268 for key, value in defaults.items(): 269 if key not in self._data: 270 self._data[key] = value 271 272 def WriteTo(self, target_path): 273 with open(target_path, 'wb') as fp: 274 if sys.version_info.major == 2: 275 plistlib.writePlist(self._data, fp) 276 else: 277 plistlib.dump(self._data, fp) 278 279 280def FindProvisioningProfile(provisioning_profile_paths, bundle_identifier, 281 required): 282 """Finds mobile provisioning profile to use to sign bundle. 283 284 Args: 285 bundle_identifier: the identifier of the bundle to sign. 286 287 Returns: 288 The ProvisioningProfile object that can be used to sign the Bundle 289 object or None if no matching provisioning profile was found. 290 """ 291 if not provisioning_profile_paths: 292 provisioning_profile_paths = glob.glob( 293 os.path.join(GetProvisioningProfilesDir(), '*.mobileprovision')) 294 295 # Iterate over all installed mobile provisioning profiles and filter those 296 # that can be used to sign the bundle, ignoring expired ones. 297 now = datetime.datetime.now() 298 valid_provisioning_profiles = [] 299 one_hour = datetime.timedelta(0, 3600) 300 for provisioning_profile_path in provisioning_profile_paths: 301 provisioning_profile = ProvisioningProfile(provisioning_profile_path) 302 if provisioning_profile.expiration_date - now < one_hour: 303 sys.stderr.write( 304 'Warning: ignoring expired provisioning profile: %s.\n' % 305 provisioning_profile_path) 306 continue 307 if provisioning_profile.ValidToSignBundle(bundle_identifier): 308 valid_provisioning_profiles.append(provisioning_profile) 309 310 if not valid_provisioning_profiles: 311 if required: 312 sys.stderr.write( 313 'Error: no mobile provisioning profile found for "%s" in %s.\n' % 314 (bundle_identifier, provisioning_profile_paths)) 315 sys.exit(1) 316 return None 317 318 # Select the most specific mobile provisioning profile, i.e. the one with 319 # the longest application identifier pattern (prefer the one with the latest 320 # expiration date as a secondary criteria). 321 selected_provisioning_profile = max( 322 valid_provisioning_profiles, 323 key=lambda p: (len(p.application_identifier_pattern), p.expiration_date)) 324 325 one_week = datetime.timedelta(7) 326 if selected_provisioning_profile.expiration_date - now < 2 * one_week: 327 sys.stderr.write( 328 'Warning: selected provisioning profile will expire soon: %s' % 329 selected_provisioning_profile.path) 330 return selected_provisioning_profile 331 332 333def CodeSignBundle(bundle_path, identity, extra_args): 334 process = subprocess.Popen( 335 ['xcrun', 'codesign', '--force', '--sign', identity, '--timestamp=none'] + 336 list(extra_args) + [bundle_path], 337 stderr=subprocess.PIPE, 338 universal_newlines=True) 339 _, stderr = process.communicate() 340 if process.returncode: 341 sys.stderr.write(stderr) 342 sys.exit(process.returncode) 343 for line in stderr.splitlines(): 344 if line.endswith(': replacing existing signature'): 345 # Ignore warning about replacing existing signature as this should only 346 # happen when re-signing system frameworks (and then it is expected). 347 continue 348 sys.stderr.write(line) 349 sys.stderr.write('\n') 350 351 352def InstallSystemFramework(framework_path, bundle_path, args): 353 """Install framework from |framework_path| to |bundle| and code-re-sign it.""" 354 installed_framework_path = os.path.join( 355 bundle_path, 'Frameworks', os.path.basename(framework_path)) 356 357 if os.path.isfile(framework_path): 358 shutil.copy(framework_path, installed_framework_path) 359 elif os.path.isdir(framework_path): 360 if os.path.exists(installed_framework_path): 361 shutil.rmtree(installed_framework_path) 362 shutil.copytree(framework_path, installed_framework_path) 363 364 CodeSignBundle(installed_framework_path, args.identity, 365 ['--deep', '--preserve-metadata=identifier,entitlements,flags']) 366 367 368def GenerateEntitlements(path, provisioning_profile, bundle_identifier): 369 """Generates an entitlements file. 370 371 Args: 372 path: path to the entitlements template file 373 provisioning_profile: ProvisioningProfile object to use, may be None 374 bundle_identifier: identifier of the bundle to sign. 375 """ 376 entitlements = Entitlements(path) 377 if provisioning_profile: 378 entitlements.LoadDefaults(provisioning_profile.entitlements) 379 app_identifier_prefix = \ 380 provisioning_profile.application_identifier_prefix + '.' 381 else: 382 app_identifier_prefix = '*.' 383 entitlements.ExpandVariables({ 384 'CFBundleIdentifier': bundle_identifier, 385 'AppIdentifierPrefix': app_identifier_prefix, 386 }) 387 return entitlements 388 389 390def GenerateBundleInfoPlist(bundle, plist_compiler, partial_plist): 391 """Generates the bundle Info.plist for a list of partial .plist files. 392 393 Args: 394 bundle: a Bundle instance 395 plist_compiler: string, path to the Info.plist compiler 396 partial_plist: list of path to partial .plist files to merge 397 """ 398 399 # Filter empty partial .plist files (this happens if an application 400 # does not compile any asset catalog, in which case the partial .plist 401 # file from the asset catalog compilation step is just a stamp file). 402 filtered_partial_plist = [] 403 for plist in partial_plist: 404 plist_size = os.stat(plist).st_size 405 if plist_size: 406 filtered_partial_plist.append(plist) 407 408 # Invoke the plist_compiler script. It needs to be a python script. 409 subprocess.check_call([ 410 'python3', 411 plist_compiler, 412 'merge', 413 '-f', 414 'binary1', 415 '-o', 416 bundle.info_plist_path, 417 ] + filtered_partial_plist) 418 419 420class Action(object): 421 """Class implementing one action supported by the script.""" 422 423 @classmethod 424 def Register(cls, subparsers): 425 parser = subparsers.add_parser(cls.name, help=cls.help) 426 parser.set_defaults(func=cls._Execute) 427 cls._Register(parser) 428 429 430class CodeSignBundleAction(Action): 431 """Class implementing the code-sign-bundle action.""" 432 433 name = 'code-sign-bundle' 434 help = 'perform code signature for a bundle' 435 436 @staticmethod 437 def _Register(parser): 438 parser.add_argument( 439 '--entitlements', '-e', dest='entitlements_path', 440 help='path to the entitlements file to use') 441 parser.add_argument( 442 'path', help='path to the iOS bundle to codesign') 443 parser.add_argument( 444 '--identity', '-i', required=True, 445 help='identity to use to codesign') 446 parser.add_argument( 447 '--binary', '-b', required=True, 448 help='path to the iOS bundle binary') 449 parser.add_argument( 450 '--framework', '-F', action='append', default=[], dest='frameworks', 451 help='install and resign system framework') 452 parser.add_argument( 453 '--disable-code-signature', action='store_true', dest='no_signature', 454 help='disable code signature') 455 parser.add_argument( 456 '--disable-embedded-mobileprovision', action='store_false', 457 default=True, dest='embedded_mobileprovision', 458 help='disable finding and embedding mobileprovision') 459 parser.add_argument( 460 '--platform', '-t', required=True, 461 help='platform the signed bundle is targeting') 462 parser.add_argument( 463 '--partial-info-plist', '-p', action='append', default=[], 464 help='path to partial Info.plist to merge to create bundle Info.plist') 465 parser.add_argument( 466 '--plist-compiler-path', '-P', action='store', 467 help='path to the plist compiler script (for --partial-info-plist)') 468 parser.add_argument( 469 '--mobileprovision', 470 '-m', 471 action='append', 472 default=[], 473 dest='mobileprovision_files', 474 help='list of mobileprovision files to use. If empty, uses the files ' + 475 'in $HOME/Library/MobileDevice/Provisioning Profiles') 476 parser.set_defaults(no_signature=False) 477 478 @staticmethod 479 def _Execute(args): 480 if not args.identity: 481 args.identity = '-' 482 483 bundle = Bundle(args.path, args.platform) 484 485 if args.partial_info_plist: 486 GenerateBundleInfoPlist(bundle, args.plist_compiler_path, 487 args.partial_info_plist) 488 489 # The bundle Info.plist may have been updated by GenerateBundleInfoPlist() 490 # above. Load the bundle information from Info.plist after the modification 491 # have been written to disk. 492 bundle.Load() 493 494 # According to Apple documentation, the application binary must be the same 495 # as the bundle name without the .app suffix. See crbug.com/740476 for more 496 # information on what problem this can cause. 497 # 498 # To prevent this class of error, fail with an error if the binary name is 499 # incorrect in the Info.plist as it is not possible to update the value in 500 # Info.plist at this point (the file has been copied by a different target 501 # and ninja would consider the build dirty if it was updated). 502 # 503 # Also checks that the name of the bundle is correct too (does not cause the 504 # build to be considered dirty, but still terminate the script in case of an 505 # incorrect bundle name). 506 # 507 # Apple documentation is available at: 508 # https://developer.apple.com/library/content/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html 509 bundle_name = os.path.splitext(os.path.basename(bundle.path))[0] 510 errors = bundle.Validate({ 511 'CFBundleName': bundle_name, 512 'CFBundleExecutable': bundle_name, 513 }) 514 if errors: 515 for key in sorted(errors): 516 value, expected_value = errors[key] 517 sys.stderr.write('%s: error: %s value incorrect: %s != %s\n' % ( 518 bundle.path, key, value, expected_value)) 519 sys.stderr.flush() 520 sys.exit(1) 521 522 # Delete existing embedded mobile provisioning. 523 embedded_provisioning_profile = os.path.join( 524 bundle.path, 'embedded.mobileprovision') 525 if os.path.isfile(embedded_provisioning_profile): 526 os.unlink(embedded_provisioning_profile) 527 528 # Delete existing code signature. 529 if os.path.exists(bundle.signature_dir): 530 shutil.rmtree(bundle.signature_dir) 531 532 # Install system frameworks if requested. 533 for framework_path in args.frameworks: 534 InstallSystemFramework(framework_path, args.path, args) 535 536 # Copy main binary into bundle. 537 if not os.path.isdir(bundle.executable_dir): 538 os.makedirs(bundle.executable_dir) 539 shutil.copy(args.binary, bundle.binary_path) 540 541 if bundle.kind == 'mac_framework': 542 # Create Versions/Current -> Versions/A symlink 543 CreateSymlink('A', os.path.join(bundle.path, 'Versions/Current')) 544 545 # Create $binary_name -> Versions/Current/$binary_name symlink 546 CreateSymlink(os.path.join('Versions/Current', bundle.binary_name), 547 os.path.join(bundle.path, bundle.binary_name)) 548 549 # Create optional symlinks. 550 for name in ('Headers', 'Resources', 'Modules'): 551 target = os.path.join(bundle.path, 'Versions/A', name) 552 if os.path.exists(target): 553 CreateSymlink(os.path.join('Versions/Current', name), 554 os.path.join(bundle.path, name)) 555 else: 556 obsolete_path = os.path.join(bundle.path, name) 557 if os.path.exists(obsolete_path): 558 os.unlink(obsolete_path) 559 560 if args.no_signature: 561 return 562 563 codesign_extra_args = [] 564 565 if args.embedded_mobileprovision: 566 # Find mobile provisioning profile and embeds it into the bundle (if a 567 # code signing identify has been provided, fails if no valid mobile 568 # provisioning is found). 569 provisioning_profile_required = args.identity != '-' 570 provisioning_profile = FindProvisioningProfile( 571 args.mobileprovision_files, bundle.identifier, 572 provisioning_profile_required) 573 if provisioning_profile and not args.platform.endswith('simulator'): 574 provisioning_profile.Install(embedded_provisioning_profile) 575 576 if args.entitlements_path is not None: 577 temporary_entitlements_file = \ 578 tempfile.NamedTemporaryFile(suffix='.xcent') 579 codesign_extra_args.extend( 580 ['--entitlements', temporary_entitlements_file.name]) 581 582 entitlements = GenerateEntitlements( 583 args.entitlements_path, provisioning_profile, bundle.identifier) 584 entitlements.WriteTo(temporary_entitlements_file.name) 585 586 CodeSignBundle(bundle.path, args.identity, codesign_extra_args) 587 588 589class CodeSignFileAction(Action): 590 """Class implementing code signature for a single file.""" 591 592 name = 'code-sign-file' 593 help = 'code-sign a single file' 594 595 @staticmethod 596 def _Register(parser): 597 parser.add_argument( 598 'path', help='path to the file to codesign') 599 parser.add_argument( 600 '--identity', '-i', required=True, 601 help='identity to use to codesign') 602 parser.add_argument( 603 '--output', '-o', 604 help='if specified copy the file to that location before signing it') 605 parser.set_defaults(sign=True) 606 607 @staticmethod 608 def _Execute(args): 609 if not args.identity: 610 args.identity = '-' 611 612 install_path = args.path 613 if args.output: 614 615 if os.path.isfile(args.output): 616 os.unlink(args.output) 617 elif os.path.isdir(args.output): 618 shutil.rmtree(args.output) 619 620 if os.path.isfile(args.path): 621 shutil.copy(args.path, args.output) 622 elif os.path.isdir(args.path): 623 shutil.copytree(args.path, args.output) 624 625 install_path = args.output 626 627 CodeSignBundle(install_path, args.identity, 628 ['--deep', '--preserve-metadata=identifier,entitlements']) 629 630 631class GenerateEntitlementsAction(Action): 632 """Class implementing the generate-entitlements action.""" 633 634 name = 'generate-entitlements' 635 help = 'generate entitlements file' 636 637 @staticmethod 638 def _Register(parser): 639 parser.add_argument( 640 '--entitlements', '-e', dest='entitlements_path', 641 help='path to the entitlements file to use') 642 parser.add_argument( 643 'path', help='path to the entitlements file to generate') 644 parser.add_argument( 645 '--info-plist', '-p', required=True, 646 help='path to the bundle Info.plist') 647 parser.add_argument( 648 '--mobileprovision', 649 '-m', 650 action='append', 651 default=[], 652 dest='mobileprovision_files', 653 help='set of mobileprovision files to use. If empty, uses the files ' + 654 'in $HOME/Library/MobileDevice/Provisioning Profiles') 655 656 @staticmethod 657 def _Execute(args): 658 info_plist = LoadPlistFile(args.info_plist) 659 bundle_identifier = info_plist['CFBundleIdentifier'] 660 provisioning_profile = FindProvisioningProfile(args.mobileprovision_files, 661 bundle_identifier, False) 662 entitlements = GenerateEntitlements( 663 args.entitlements_path, provisioning_profile, bundle_identifier) 664 entitlements.WriteTo(args.path) 665 666 667class FindProvisioningProfileAction(Action): 668 """Class implementing the find-codesign-identity action.""" 669 670 name = 'find-provisioning-profile' 671 help = 'find provisioning profile for use by Xcode project generator' 672 673 @staticmethod 674 def _Register(parser): 675 parser.add_argument('--bundle-id', 676 '-b', 677 required=True, 678 help='bundle identifier') 679 parser.add_argument( 680 '--mobileprovision', 681 '-m', 682 action='append', 683 default=[], 684 dest='mobileprovision_files', 685 help='set of mobileprovision files to use. If empty, uses the files ' + 686 'in $HOME/Library/MobileDevice/Provisioning Profiles') 687 688 @staticmethod 689 def _Execute(args): 690 provisioning_profile_info = {} 691 provisioning_profile = FindProvisioningProfile(args.mobileprovision_files, 692 args.bundle_id, False) 693 for key in ('team_identifier', 'name'): 694 if provisioning_profile: 695 provisioning_profile_info[key] = getattr(provisioning_profile, key) 696 else: 697 provisioning_profile_info[key] = '' 698 print(json.dumps(provisioning_profile_info)) 699 700 701def Main(): 702 # Cache this codec so that plistlib can find it. See 703 # https://crbug.com/999461#c12 for more details. 704 codecs.lookup('utf-8') 705 706 parser = argparse.ArgumentParser('codesign iOS bundles') 707 subparsers = parser.add_subparsers() 708 709 actions = [ 710 CodeSignBundleAction, 711 CodeSignFileAction, 712 GenerateEntitlementsAction, 713 FindProvisioningProfileAction, 714 ] 715 716 for action in actions: 717 action.Register(subparsers) 718 719 args = parser.parse_args() 720 args.func(args) 721 722 723if __name__ == '__main__': 724 sys.exit(Main()) 725