1# Copyright 2016 The Chromium Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5import argparse 6import datetime 7import fnmatch 8import glob 9import os 10import plistlib 11import shutil 12import subprocess 13import sys 14import tempfile 15 16 17def GetProvisioningProfilesDir(): 18 """Returns the location of the installed mobile provisioning profiles. 19 20 Returns: 21 The path to the directory containing the installed mobile provisioning 22 profiles as a string. 23 """ 24 return os.path.join( 25 os.environ['HOME'], 'Library', 'MobileDevice', 'Provisioning Profiles') 26 27 28def LoadPlistFile(plist_path): 29 """Loads property list file at |plist_path|. 30 31 Args: 32 plist_path: path to the property list file to load. 33 34 Returns: 35 The content of the property list file as a python object. 36 """ 37 return plistlib.readPlistFromString(subprocess.check_output([ 38 'xcrun', 'plutil', '-convert', 'xml1', '-o', '-', plist_path])) 39 40 41class Bundle(object): 42 """Wraps a bundle.""" 43 44 def __init__(self, bundle_path): 45 """Initializes the Bundle object with data from bundle Info.plist file.""" 46 self._path = bundle_path 47 self._data = LoadPlistFile(os.path.join(self._path, 'Info.plist')) 48 49 @property 50 def path(self): 51 return self._path 52 53 @property 54 def identifier(self): 55 return self._data['CFBundleIdentifier'] 56 57 @property 58 def binary_path(self): 59 return os.path.join(self._path, self._data['CFBundleExecutable']) 60 61 62class ProvisioningProfile(object): 63 """Wraps a mobile provisioning profile file.""" 64 65 def __init__(self, provisioning_profile_path): 66 """Initializes the ProvisioningProfile with data from profile file.""" 67 self._path = provisioning_profile_path 68 self._data = plistlib.readPlistFromString(subprocess.check_output([ 69 'xcrun', 'security', 'cms', '-D', '-u', 'certUsageAnyCA', 70 '-i', provisioning_profile_path])) 71 72 @property 73 def path(self): 74 return self._path 75 76 @property 77 def application_identifier_pattern(self): 78 return self._data.get('Entitlements', {}).get('application-identifier', '') 79 80 @property 81 def team_identifier(self): 82 return self._data.get('TeamIdentifier', [''])[0] 83 84 @property 85 def entitlements(self): 86 return self._data.get('Entitlements', {}) 87 88 @property 89 def expiration_date(self): 90 return self._data.get('ExpirationDate', datetime.datetime.now()) 91 92 def ValidToSignBundle(self, bundle_identifier): 93 """Checks whether the provisioning profile can sign bundle_identifier. 94 95 Args: 96 bundle_identifier: the identifier of the bundle that needs to be signed. 97 98 Returns: 99 True if the mobile provisioning profile can be used to sign a bundle 100 with the corresponding bundle_identifier, False otherwise. 101 """ 102 return fnmatch.fnmatch( 103 '%s.%s' % (self.team_identifier, bundle_identifier), 104 self.application_identifier_pattern) 105 106 def Install(self, installation_path): 107 """Copies mobile provisioning profile info to |installation_path|.""" 108 shutil.copy2(self.path, installation_path) 109 110 111class Entitlements(object): 112 """Wraps an Entitlement plist file.""" 113 114 def __init__(self, entitlements_path): 115 """Initializes Entitlements object from entitlement file.""" 116 self._path = entitlements_path 117 self._data = LoadPlistFile(self._path) 118 119 @property 120 def path(self): 121 return self._path 122 123 def ExpandVariables(self, substitutions): 124 self._data = self._ExpandVariables(self._data, substitutions) 125 126 def _ExpandVariables(self, data, substitutions): 127 if isinstance(data, str): 128 for key, substitution in substitutions.iteritems(): 129 data = data.replace('$(%s)' % (key,), substitution) 130 return data 131 132 if isinstance(data, dict): 133 for key, value in data.iteritems(): 134 data[key] = self._ExpandVariables(value, substitutions) 135 return data 136 137 if isinstance(data, list): 138 for i, value in enumerate(data): 139 data[i] = self._ExpandVariables(value, substitutions) 140 141 return data 142 143 def LoadDefaults(self, defaults): 144 for key, value in defaults.iteritems(): 145 if key not in self._data: 146 self._data[key] = value 147 148 def WriteTo(self, target_path): 149 plistlib.writePlist(self._data, target_path) 150 151 152def FindProvisioningProfile(bundle_identifier, required): 153 """Finds mobile provisioning profile to use to sign bundle. 154 155 Args: 156 bundle_identifier: the identifier of the bundle to sign. 157 158 Returns: 159 The ProvisioningProfile object that can be used to sign the Bundle 160 object or None if no matching provisioning profile was found. 161 """ 162 provisioning_profile_paths = glob.glob( 163 os.path.join(GetProvisioningProfilesDir(), '*.mobileprovision')) 164 165 # Iterate over all installed mobile provisioning profiles and filter those 166 # that can be used to sign the bundle, ignoring expired ones. 167 now = datetime.datetime.now() 168 valid_provisioning_profiles = [] 169 one_hour = datetime.timedelta(0, 3600) 170 for provisioning_profile_path in provisioning_profile_paths: 171 provisioning_profile = ProvisioningProfile(provisioning_profile_path) 172 if provisioning_profile.expiration_date - now < one_hour: 173 sys.stderr.write( 174 'Warning: ignoring expired provisioning profile: %s.\n' % 175 provisioning_profile_path) 176 continue 177 if provisioning_profile.ValidToSignBundle(bundle_identifier): 178 valid_provisioning_profiles.append(provisioning_profile) 179 180 if not valid_provisioning_profiles: 181 if required: 182 sys.stderr.write( 183 'Error: no mobile provisioning profile found for "%s".\n' % 184 bundle_identifier) 185 sys.exit(1) 186 return None 187 188 # Select the most specific mobile provisioning profile, i.e. the one with 189 # the longest application identifier pattern (prefer the one with the latest 190 # expiration date as a secondary criteria). 191 selected_provisioning_profile = max( 192 valid_provisioning_profiles, 193 key=lambda p: (len(p.application_identifier_pattern), p.expiration_date)) 194 195 one_week = datetime.timedelta(7) 196 if selected_provisioning_profile.expiration_date - now < 2 * one_week: 197 sys.stderr.write( 198 'Warning: selected provisioning profile will expire soon: %s' % 199 selected_provisioning_profile.path) 200 return selected_provisioning_profile 201 202 203def CodeSignBundle(bundle_path, identity, extra_args): 204 process = subprocess.Popen(['xcrun', 'codesign', '--force', '--sign', 205 identity, '--timestamp=none'] + list(extra_args) + [bundle_path], 206 stderr=subprocess.PIPE) 207 _, stderr = process.communicate() 208 if process.returncode: 209 sys.stderr.write(stderr) 210 sys.exit(process.returncode) 211 for line in stderr.splitlines(): 212 if line.endswith(': replacing existing signature'): 213 # Ignore warning about replacing existing signature as this should only 214 # happen when re-signing system frameworks (and then it is expected). 215 continue 216 sys.stderr.write(line) 217 sys.stderr.write('\n') 218 219 220def InstallSystemFramework(framework_path, bundle_path, args): 221 """Install framework from |framework_path| to |bundle| and code-re-sign it.""" 222 installed_framework_path = os.path.join( 223 bundle_path, 'Frameworks', os.path.basename(framework_path)) 224 225 if os.path.exists(installed_framework_path): 226 shutil.rmtree(installed_framework_path) 227 228 shutil.copytree(framework_path, installed_framework_path) 229 CodeSignBundle(installed_framework_path, args.identity, 230 ['--deep', '--preserve-metadata=identifier,entitlements']) 231 232 233def GenerateEntitlements(path, provisioning_profile, bundle_identifier): 234 """Generates an entitlements file. 235 236 Args: 237 path: path to the entitlements template file 238 provisioning_profile: ProvisioningProfile object to use, may be None 239 bundle_identifier: identifier of the bundle to sign. 240 """ 241 entitlements = Entitlements(path) 242 if provisioning_profile: 243 entitlements.LoadDefaults(provisioning_profile.entitlements) 244 app_identifier_prefix = provisioning_profile.team_identifier + '.' 245 else: 246 app_identifier_prefix = '*.' 247 entitlements.ExpandVariables({ 248 'CFBundleIdentifier': bundle_identifier, 249 'AppIdentifierPrefix': app_identifier_prefix, 250 }) 251 return entitlements 252 253 254class Action(object): 255 """Class implementing one action supported by the script.""" 256 257 @classmethod 258 def Register(cls, subparsers): 259 parser = subparsers.add_parser(cls.name, help=cls.help) 260 parser.set_defaults(func=cls._Execute) 261 cls._Register(parser) 262 263 264class CodeSignBundleAction(Action): 265 """Class implementing the code-sign-bundle action.""" 266 267 name = 'code-sign-bundle' 268 help = 'perform code signature for a bundle' 269 270 @staticmethod 271 def _Register(parser): 272 parser.add_argument( 273 '--entitlements', '-e', dest='entitlements_path', 274 help='path to the entitlements file to use') 275 parser.add_argument( 276 'path', help='path to the iOS bundle to codesign') 277 parser.add_argument( 278 '--identity', '-i', required=True, 279 help='identity to use to codesign') 280 parser.add_argument( 281 '--binary', '-b', required=True, 282 help='path to the iOS bundle binary') 283 parser.add_argument( 284 '--framework', '-F', action='append', default=[], dest='frameworks', 285 help='install and resign system framework') 286 parser.add_argument( 287 '--disable-code-signature', action='store_true', dest='no_signature', 288 help='disable code signature') 289 parser.add_argument( 290 '--platform', '-t', required=True, 291 help='platform the signed bundle is targetting') 292 parser.set_defaults(no_signature=False) 293 294 @staticmethod 295 def _Execute(args): 296 if not args.identity: 297 args.identity = '-' 298 299 bundle = Bundle(args.path) 300 301 # Delete existing embedded mobile provisioning. 302 embedded_provisioning_profile = os.path.join( 303 bundle.path, 'embedded.mobileprovision') 304 if os.path.isfile(embedded_provisioning_profile): 305 os.unlink(embedded_provisioning_profile) 306 307 # Delete existing code signature. 308 signature_file = os.path.join(args.path, '_CodeSignature', 'CodeResources') 309 if os.path.isfile(signature_file): 310 shutil.rmtree(os.path.dirname(signature_file)) 311 312 # Install system frameworks if requested. 313 for framework_path in args.frameworks: 314 InstallSystemFramework(framework_path, args.path, args) 315 316 # Copy main binary into bundle. 317 if os.path.isfile(bundle.binary_path): 318 os.unlink(bundle.binary_path) 319 shutil.copy(args.binary, bundle.binary_path) 320 321 if args.no_signature: 322 return 323 324 codesign_extra_args = [] 325 326 # Find mobile provisioning profile and embeds it into the bundle (if a code 327 # signing identify has been provided, fails if no valid mobile provisioning 328 # is found). 329 provisioning_profile_required = args.identity != '-' 330 provisioning_profile = FindProvisioningProfile( 331 bundle.identifier, provisioning_profile_required) 332 if provisioning_profile and args.platform != 'iphonesimulator': 333 provisioning_profile.Install(embedded_provisioning_profile) 334 335 temporary_entitlements_file = tempfile.NamedTemporaryFile(suffix='.xcent') 336 codesign_extra_args.extend( 337 ['--entitlements', temporary_entitlements_file.name]) 338 339 entitlements = GenerateEntitlements( 340 args.entitlements_path, provisioning_profile, bundle.identifier) 341 entitlements.WriteTo(temporary_entitlements_file.name) 342 343 CodeSignBundle(bundle.path, args.identity, codesign_extra_args) 344 345 346class CodeSignFileAction(Action): 347 """Class implementing code signature for a single file.""" 348 349 name = 'code-sign-file' 350 help = 'code-sign a single file' 351 352 @staticmethod 353 def _Register(parser): 354 parser.add_argument( 355 'path', help='path to the file to codesign') 356 parser.add_argument( 357 '--identity', '-i', required=True, 358 help='identity to use to codesign') 359 parser.add_argument( 360 '--output', '-o', 361 help='if specified copy the file to that location before signing it') 362 parser.set_defaults(sign=True) 363 364 @staticmethod 365 def _Execute(args): 366 if not args.identity: 367 args.identity = '-' 368 369 install_path = args.path 370 if args.output: 371 372 if os.path.isfile(args.output): 373 os.unlink(args.output) 374 elif os.path.isdir(args.output): 375 shutil.rmtree(args.output) 376 377 if os.path.isfile(args.path): 378 shutil.copy(args.path, args.output) 379 elif os.path.isdir(args.path): 380 shutil.copytree(args.path, args.output) 381 382 install_path = args.output 383 384 CodeSignBundle(install_path, args.identity, 385 ['--deep', '--preserve-metadata=identifier,entitlements']) 386 387 388class GenerateEntitlementsAction(Action): 389 """Class implementing the generate-entitlements action.""" 390 391 name = 'generate-entitlements' 392 help = 'generate entitlements file' 393 394 @staticmethod 395 def _Register(parser): 396 parser.add_argument( 397 '--entitlements', '-e', dest='entitlements_path', 398 help='path to the entitlements file to use') 399 parser.add_argument( 400 'path', help='path to the entitlements file to generate') 401 parser.add_argument( 402 '--info-plist', '-p', required=True, 403 help='path to the bundle Info.plist') 404 405 @staticmethod 406 def _Execute(args): 407 info_plist = LoadPlistFile(args.info_plist) 408 bundle_identifier = info_plist['CFBundleIdentifier'] 409 provisioning_profile = FindProvisioningProfile(bundle_identifier, False) 410 entitlements = GenerateEntitlements( 411 args.entitlements_path, provisioning_profile, bundle_identifier) 412 entitlements.WriteTo(args.path) 413 414 415def Main(): 416 parser = argparse.ArgumentParser('codesign iOS bundles') 417 parser.add_argument('--developer_dir', required=False, 418 help='Path to Xcode.') 419 subparsers = parser.add_subparsers() 420 421 actions = [ 422 CodeSignBundleAction, 423 CodeSignFileAction, 424 GenerateEntitlementsAction, 425 ] 426 427 for action in actions: 428 action.Register(subparsers) 429 430 args = parser.parse_args() 431 if args.developer_dir: 432 os.environ['DEVELOPER_DIR'] = args.developer_dir 433 args.func(args) 434 435 436if __name__ == '__main__': 437 sys.exit(Main()) 438