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