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