import argparse
import codecs
import datetime
import fnmatch
import glob
import json
import os
import plistlib
import re
import shutil
import subprocess
import stat
import sys
import tempfile
ALLOWED_KEYS = [
"aps-environment",
"com.apple.developer.team-identifier",
"get-task-allow",
"keychain-access-groups",
]
ICON_SUFFIX_PATTERN = '(\\d+(?:\\.\\d+)?)x\\1(@[23]x)?(~ipad)?\\.png'
BUNDLE_ICON_PATTERNS_MAP = {
".app": re.compile('AppIcon' + ICON_SUFFIX_PATTERN),
".appex": re.compile('ExtensionIcon' + ICON_SUFFIX_PATTERN),
}
if sys.version_info.major < 3:
basestring_compat = basestring
else:
basestring_compat = str
class FileListAction(argparse.Action):
def __init__(self, option_strings, dest, nargs=None, **kwds):
if nargs is not None:
raise ValueError("nargs not allowed")
super().__init__(option_strings, dest, nargs, **kwds)
def __call__(self, parser, namespace, values, option_strings):
dest = getattr(namespace, self.dest)
with open(values, 'r', encoding='utf-8') as stream:
for line in stream:
path = line[:-1]
dest.append(path)
def GetProvisioningProfilesDirs():
paths = []
paths.append(
os.path.join(os.environ['HOME'], 'Library', 'MobileDevice',
'Provisioning Profiles'))
paths.append(
os.path.join(os.environ['HOME'], 'Library', 'Developer', 'Xcode',
'UserData', 'Provisioning Profiles'))
return paths
def ReadPlistFromString(plist_bytes):
if sys.version_info.major == 2:
return plistlib.readPlistFromString(plist_bytes)
else:
return plistlib.loads(plist_bytes)
def LoadPlistFile(plist_path):
if sys.version_info.major == 2:
return plistlib.readPlistFromString(
subprocess.check_output(
['xcrun', 'plutil', '-convert', 'xml1', '-o', '-', plist_path]))
else:
with open(plist_path, 'rb') as fp:
return plistlib.load(fp)
def CreateSymlink(value, location):
target = os.path.join(os.path.dirname(location), value)
if os.path.exists(location):
os.unlink(location)
os.symlink(value, location)
class Bundle(object):
def __init__(self, bundle_path, platform):
self._path = bundle_path
self._kind = Bundle.Kind(platform, os.path.splitext(bundle_path)[-1])
self._data = None
def Load(self):
self._data = LoadPlistFile(self.info_plist_path)
@staticmethod
def Kind(platform, extension):
if platform in ('iphoneos', 'iphonesimulator'):
return 'ios'
if platform == 'macosx':
if extension == '.framework':
return 'mac_framework'
return 'mac'
if platform in ('watchos', 'watchsimulator'):
return 'watchos'
if platform in ('appletvos', 'appletvsimulator'):
return 'tvos'
raise ValueError('unknown bundle type %s for %s' % (extension, platform))
@property
def kind(self):
return self._kind
@property
def path(self):
return self._path
@property
def contents_dir(self):
if self._kind == 'mac':
return os.path.join(self.path, 'Contents')
if self._kind == 'mac_framework':
return os.path.join(self.path, 'Versions/A')
return self.path
@property
def executable_dir(self):
if self._kind == 'mac':
return os.path.join(self.contents_dir, 'MacOS')
return self.contents_dir
@property
def resources_dir(self):
if self._kind == 'mac' or self._kind == 'mac_framework':
return os.path.join(self.contents_dir, 'Resources')
return self.path
@property
def info_plist_path(self):
if self._kind == 'mac_framework':
return os.path.join(self.resources_dir, 'Info.plist')
return os.path.join(self.contents_dir, 'Info.plist')
@property
def signature_dir(self):
return os.path.join(self.contents_dir, '_CodeSignature')
@property
def relative_signature_dir(self):
return os.path.relpath(self.signature_dir, self.path)
@property
def embedded_mobileprovision(self):
return os.path.join(self.path, 'embedded.mobileprovision')
@property
def relative_embedded_mobileprovision(self):
return os.path.relpath(self.embedded_mobileprovision, self.path)
@property
def identifier(self):
return self._data['CFBundleIdentifier']
@property
def binary_name(self):
return self._data['CFBundleExecutable']
@property
def binary_path(self):
return os.path.join(self.executable_dir, self.binary_name)
def Validate(self, expected_mappings):
errors = {}
for key, expected_value in expected_mappings.items():
if key in self._data:
value = self._data[key]
if value != expected_value:
errors[key] = (value, expected_value)
return errors
class ProvisioningProfile(object):
def __init__(self, provisioning_profile_path):
self._path = provisioning_profile_path
self._data = ReadPlistFromString(
subprocess.check_output([
'xcrun', 'security', 'cms', '-D', '-u', 'certUsageAnyCA', '-i',
provisioning_profile_path
]))
@property
def path(self):
return self._path
@property
def team_identifier(self):
return self._data.get('TeamIdentifier', [''])[0]
@property
def name(self):
return self._data.get('Name', '')
@property
def application_identifier_pattern(self):
return self._data.get('Entitlements', {}).get('application-identifier', '')
@property
def application_identifier_prefix(self):
return self._data.get('ApplicationIdentifierPrefix', [''])[0]
@property
def entitlements(self):
return self._data.get('Entitlements', {})
@property
def expiration_date(self):
return self._data.get('ExpirationDate', datetime.datetime.now())
def ValidToSignBundle(self, bundle_identifier):
return fnmatch.fnmatch(
'%s.%s' % (self.application_identifier_prefix, bundle_identifier),
self.application_identifier_pattern)
def Install(self, installation_path):
shutil.copy2(self.path, installation_path)
st = os.stat(installation_path)
os.chmod(installation_path, st.st_mode | stat.S_IWUSR)
class Entitlements(object):
def __init__(self, entitlements_path):
self._path = entitlements_path
self._data = LoadPlistFile(self._path)
@property
def path(self):
return self._path
def ExpandVariables(self, substitutions):
self._data = self._ExpandVariables(self._data, substitutions)
def _ExpandVariables(self, data, substitutions):
if isinstance(data, basestring_compat):
for key, substitution in substitutions.items():
data = data.replace('$(%s)' % (key,), substitution)
return data
if isinstance(data, dict):
for key, value in data.items():
data[key] = self._ExpandVariables(value, substitutions)
return data
if isinstance(data, list):
for i, value in enumerate(data):
data[i] = self._ExpandVariables(value, substitutions)
return data
def LoadDefaults(self, defaults):
for key, value in defaults.items():
if key not in self._data and key in ALLOWED_KEYS:
self._data[key] = value
def WriteTo(self, target_path):
with open(target_path, 'wb') as fp:
if sys.version_info.major == 2:
plistlib.writePlist(self._data, fp)
else:
plistlib.dump(self._data, fp)
def FindProvisioningProfile(provisioning_profile_paths, bundle_identifier,
required):
if not provisioning_profile_paths:
for path in GetProvisioningProfilesDirs():
provisioning_profile_paths.extend(
glob.glob(os.path.join(path, '*.mobileprovision')))
now = datetime.datetime.now()
valid_provisioning_profiles = []
one_hour = datetime.timedelta(0, 3600)
for provisioning_profile_path in provisioning_profile_paths:
provisioning_profile = ProvisioningProfile(provisioning_profile_path)
if provisioning_profile.expiration_date - now < one_hour:
sys.stderr.write(
'Warning: ignoring expired provisioning profile: %s.\n' %
provisioning_profile_path)
continue
if provisioning_profile.ValidToSignBundle(bundle_identifier):
valid_provisioning_profiles.append(provisioning_profile)
if not valid_provisioning_profiles:
if required:
sys.stderr.write(
'Error: no mobile provisioning profile found for "%s" in %s.\n' %
(bundle_identifier, provisioning_profile_paths))
sys.exit(1)
return None
selected_provisioning_profile = max(
valid_provisioning_profiles,
key=lambda p: (len(p.application_identifier_pattern), p.expiration_date))
one_week = datetime.timedelta(7)
if selected_provisioning_profile.expiration_date - now < 2 * one_week:
sys.stderr.write(
'Warning: selected provisioning profile will expire soon: %s' %
selected_provisioning_profile.path)
return selected_provisioning_profile
def CodeSignBundle(bundle_path, identity, extra_args):
process = subprocess.Popen(
['xcrun', 'codesign', '--force', '--sign', identity, '--timestamp=none'] +
list(extra_args) + [bundle_path],
stderr=subprocess.PIPE,
universal_newlines=True)
_, stderr = process.communicate()
if process.returncode:
sys.stderr.write(stderr)
sys.exit(process.returncode)
for line in stderr.splitlines():
if line.endswith(': replacing existing signature'):
continue
sys.stderr.write(line)
sys.stderr.write('\n')
def IsSubPath(path, parent_path):
return path.startswith(parent_path + os.path.sep)
def DeleteItemAtPath(path):
if os.path.isdir(path):
if not os.path.islink(path):
shutil.rmtree(path)
return
os.unlink(path)
def VerifyBundleManifest(bundle, manifest):
if not manifest:
return
patterns = [
lambda p: p == bundle.relative_embedded_mobileprovision,
lambda p: IsSubPath(p, bundle.relative_signature_dir),
]
filtered = lambda path: any(map(lambda pattern: pattern(path), patterns))
manifest = set(path for path in manifest if not filtered(path))
manifest_directories = set()
for path in manifest:
dirname = os.path.dirname(path)
while dirname and dirname not in manifest_directories:
manifest_directories.add(dirname)
dirname = os.path.dirname(dirname)
bundle_icon_pattern = BUNDLE_ICON_PATTERNS_MAP.get(
os.path.splitext(bundle.path)[-1], None)
for dirpath, dirnames, filenames in os.walk(bundle.path):
reldirpath = os.path.relpath(dirpath, bundle.path)
dirnames_to_skip = []
for dirname in dirnames:
subdirpath = os.path.normpath(os.path.join(reldirpath, dirname))
if subdirpath in manifest:
dirnames_to_skip.append(dirname)
manifest.remove(subdirpath)
elif subdirpath not in manifest_directories:
dirnames_to_skip.append(dirname)
print(f'warning: deleting old directory: {subdirpath}', file=sys.stderr)
DeleteItemAtPath(os.path.join(dirpath, dirname))
if dirnames_to_skip:
dirnames[:] = list(set(dirnames) - set(dirnames_to_skip))
for filename in filenames:
filepath = os.path.normpath(os.path.join(reldirpath, filename))
if not filepath in manifest:
if not bundle_icon_pattern or not bundle_icon_pattern.match(filename):
print(f'warning: deleting old file: {filepath}', file=sys.stderr)
DeleteItemAtPath(os.path.join(dirpath, filename))
else:
manifest.remove(filepath)
if manifest:
print(f'error: {len(manifest)} missing files:', file=sys.stderr)
for filepath in sorted(manifest):
print(f' - {filepath}', file=sys.stderr)
sys.exit(1)
def InstallSystemFramework(framework_path, bundle_path, args):
installed_framework_path = os.path.join(
bundle_path, 'Frameworks', os.path.basename(framework_path))
if os.path.isfile(framework_path):
shutil.copy(framework_path, installed_framework_path)
elif os.path.isdir(framework_path):
if os.path.exists(installed_framework_path):
shutil.rmtree(installed_framework_path)
shutil.copytree(framework_path, installed_framework_path)
CodeSignBundle(installed_framework_path, args.identity,
['--deep', '--preserve-metadata=identifier,entitlements,flags'])
def VerifyLoadOrder(binary_path, expected_first_framework):
try:
output = subprocess.check_output(['otool', '-l', binary_path],
stderr=subprocess.STDOUT,
universal_newlines=True)
except subprocess.CalledProcessError as e:
sys.stderr.write('otool failed: %s\n' % e.output)
sys.exit(1)
first_dylib = None
lines = output.splitlines()
for i, line in enumerate(lines):
if line.strip() == 'cmd LC_LOAD_DYLIB':
for j in range(i + 1, min(i + 10, len(lines))):
if lines[j].strip().startswith('name '):
parts = lines[j].strip().split(' ', 1)
if len(parts) > 1:
name_line = parts[1]
first_dylib = name_line.rsplit(' (offset', 1)[0]
break
if first_dylib:
break
if not first_dylib:
sys.stderr.write(
'Error: No LC_LOAD_DYLIB found in %s, but expected %s to be first.\n' %
(binary_path, expected_first_framework))
sys.exit(1)
expected_suffix = '/%s.framework/%s' % (expected_first_framework,
expected_first_framework)
if not first_dylib.endswith(expected_suffix):
sys.stderr.write(
'Error: First LC_LOAD_DYLIB in %s is "%s", expected to end with "%s".\n'
% (binary_path, first_dylib, expected_suffix))
sys.exit(1)
def GenerateEntitlements(path, provisioning_profile, bundle_identifier):
entitlements = Entitlements(path)
if provisioning_profile:
entitlements.LoadDefaults(provisioning_profile.entitlements)
app_identifier_prefix = \
provisioning_profile.application_identifier_prefix + '.'
else:
app_identifier_prefix = '*.'
entitlements.ExpandVariables({
'CFBundleIdentifier': bundle_identifier,
'AppIdentifierPrefix': app_identifier_prefix,
})
return entitlements
def GenerateBundleInfoPlist(bundle, plist_compiler, partial_plist):
filtered_partial_plist = []
for plist in partial_plist:
plist_size = os.stat(plist).st_size
if plist_size:
filtered_partial_plist.append(plist)
subprocess.check_call([
'python3',
plist_compiler,
'merge',
'-f',
'binary1',
'-o',
bundle.info_plist_path,
] + filtered_partial_plist)
class Action(object):
@classmethod
def Register(cls, subparsers):
parser = subparsers.add_parser(cls.name, help=cls.help)
parser.set_defaults(func=cls._Execute)
cls._Register(parser)
class CodeSignBundleAction(Action):
name = 'code-sign-bundle'
help = 'perform code signature for a bundle'
@staticmethod
def _Register(parser):
parser.add_argument(
'--entitlements', '-e', dest='entitlements_path',
help='path to the entitlements file to use')
parser.add_argument(
'path', help='path to the iOS bundle to codesign')
parser.add_argument(
'--identity', '-i', required=True,
help='identity to use to codesign')
parser.add_argument(
'--binary', '-b', required=True,
help='path to the iOS bundle binary')
parser.add_argument(
'--framework', '-F', action='append', default=[], dest='frameworks',
help='install and resign system framework')
parser.add_argument(
'--disable-code-signature', action='store_true', dest='no_signature',
help='disable code signature')
parser.add_argument(
'--disable-embedded-mobileprovision', action='store_false',
default=True, dest='embedded_mobileprovision',
help='disable finding and embedding mobileprovision')
parser.add_argument(
'--platform', '-t', required=True,
help='platform the signed bundle is targeting')
parser.add_argument(
'--partial-info-plist', '-p', action='append', default=[],
help='path to partial Info.plist to merge to create bundle Info.plist')
parser.add_argument(
'--plist-compiler-path', '-P', action='store',
help='path to the plist compiler script (for --partial-info-plist)')
parser.add_argument(
'--mobileprovision',
'-m',
action='append',
default=[],
dest='mobileprovision_files',
help='list of mobileprovision files to use. If empty, uses the files ' +
'in $HOME/Library/MobileDevice/Provisioning Profiles')
parser.add_argument(
'--mobileprovision-list',
'-M',
action=FileListAction,
dest='mobileprovision_files',
help='path to a file containing a list of mobileprovision files to ' +
'use (this will behave as each "-m $line" was passsed for each line ' +
'in that file)')
parser.add_argument(
'--manifest',
'-L',
default=[],
action=FileListAction,
dest='manifest',
help='if present, path to a file containing the list of files that ' +
'are part of the bundle to codesign. The script will delete any ' +
'files found that are not listed, and will fail if any files is ' +
'missing.')
parser.add_argument(
'--verify-load-order-first',
dest='verify_load_order_first',
help='verify that the named framework is the first loaded dylib')
parser.set_defaults(no_signature=False)
@staticmethod
def _Execute(args):
if not args.identity:
args.identity = '-'
bundle = Bundle(args.path, args.platform)
if args.partial_info_plist:
GenerateBundleInfoPlist(bundle, args.plist_compiler_path,
args.partial_info_plist)
bundle.Load()
bundle_name = os.path.splitext(os.path.basename(bundle.path))[0]
errors = bundle.Validate({
'CFBundleName': bundle_name,
'CFBundleExecutable': bundle_name,
})
if errors:
for key in sorted(errors):
value, expected_value = errors[key]
sys.stderr.write('%s: error: %s value incorrect: %s != %s\n' % (
bundle.path, key, value, expected_value))
sys.stderr.flush()
sys.exit(1)
embedded_provisioning_profile = bundle.embedded_mobileprovision
if os.path.isfile(embedded_provisioning_profile):
os.unlink(embedded_provisioning_profile)
if os.path.exists(bundle.signature_dir):
shutil.rmtree(bundle.signature_dir)
for framework_path in args.frameworks:
InstallSystemFramework(framework_path, args.path, args)
if not os.path.isdir(bundle.executable_dir):
os.makedirs(bundle.executable_dir)
shutil.copy(args.binary, bundle.binary_path)
created_symlinks = []
if bundle.kind == 'mac_framework':
created_symlinks.append('Versions/Current')
CreateSymlink('A', os.path.join(bundle.path, 'Versions/Current'))
created_symlinks.append(bundle.binary_name)
CreateSymlink(os.path.join('Versions/Current', bundle.binary_name),
os.path.join(bundle.path, bundle.binary_name))
for name in ('Headers', 'Resources', 'Modules'):
target = os.path.join(bundle.path, 'Versions/A', name)
if os.path.exists(target):
created_symlinks.append(name)
CreateSymlink(os.path.join('Versions/Current', name),
os.path.join(bundle.path, name))
else:
obsolete_path = os.path.join(bundle.path, name)
if os.path.exists(obsolete_path):
os.unlink(obsolete_path)
if args.manifest:
VerifyBundleManifest(bundle, set(args.manifest) | set(created_symlinks))
if args.verify_load_order_first:
VerifyLoadOrder(bundle.binary_path, args.verify_load_order_first)
if args.no_signature:
return
codesign_extra_args = []
if args.embedded_mobileprovision:
provisioning_profile_required = args.identity != '-'
provisioning_profile = FindProvisioningProfile(
args.mobileprovision_files, bundle.identifier,
provisioning_profile_required)
if provisioning_profile and not args.platform.endswith('simulator'):
provisioning_profile.Install(embedded_provisioning_profile)
if args.entitlements_path is not None:
temporary_entitlements_file = \
tempfile.NamedTemporaryFile(suffix='.xcent')
codesign_extra_args.extend(
['--entitlements', temporary_entitlements_file.name])
entitlements = GenerateEntitlements(
args.entitlements_path, provisioning_profile, bundle.identifier)
entitlements.WriteTo(temporary_entitlements_file.name)
CodeSignBundle(bundle.path, args.identity, codesign_extra_args)
class CodeSignFileAction(Action):
name = 'code-sign-file'
help = 'code-sign a single file'
@staticmethod
def _Register(parser):
parser.add_argument(
'path', help='path to the file to codesign')
parser.add_argument(
'--identity', '-i', required=True,
help='identity to use to codesign')
parser.add_argument(
'--output', '-o',
help='if specified copy the file to that location before signing it')
parser.set_defaults(sign=True)
@staticmethod
def _Execute(args):
if not args.identity:
args.identity = '-'
install_path = args.path
if args.output:
if os.path.isfile(args.output):
os.unlink(args.output)
elif os.path.isdir(args.output):
shutil.rmtree(args.output)
if os.path.isfile(args.path):
shutil.copy(args.path, args.output)
elif os.path.isdir(args.path):
shutil.copytree(args.path, args.output)
install_path = args.output
CodeSignBundle(install_path, args.identity,
['--deep', '--preserve-metadata=identifier,entitlements'])
class GenerateEntitlementsAction(Action):
name = 'generate-entitlements'
help = 'generate entitlements file'
@staticmethod
def _Register(parser):
parser.add_argument(
'--entitlements', '-e', dest='entitlements_path',
help='path to the entitlements file to use')
parser.add_argument(
'path', help='path to the entitlements file to generate')
parser.add_argument(
'--info-plist', '-p', required=True,
help='path to the bundle Info.plist')
parser.add_argument(
'--mobileprovision',
'-m',
action='append',
default=[],
dest='mobileprovision_files',
help='set of mobileprovision files to use. If empty, uses the files ' +
'in $HOME/Library/MobileDevice/Provisioning Profiles')
parser.add_argument(
'--mobileprovision-list',
'-M',
action=FileListAction,
dest='mobileprovision_files',
help='path to a file containing a list of mobileprovision files to ' +
'use (this will behave as each "-m $line" was passsed for each line ' +
'in that file)')
@staticmethod
def _Execute(args):
info_plist = LoadPlistFile(args.info_plist)
bundle_identifier = info_plist['CFBundleIdentifier']
provisioning_profile = FindProvisioningProfile(args.mobileprovision_files,
bundle_identifier, False)
entitlements = GenerateEntitlements(
args.entitlements_path, provisioning_profile, bundle_identifier)
entitlements.WriteTo(args.path)
class FindProvisioningProfileAction(Action):
name = 'find-provisioning-profile'
help = 'find provisioning profile for use by Xcode project generator'
@staticmethod
def _Register(parser):
parser.add_argument('--bundle-id',
'-b',
required=True,
help='bundle identifier')
parser.add_argument(
'--mobileprovision',
'-m',
action='append',
default=[],
dest='mobileprovision_files',
help='set of mobileprovision files to use. If empty, uses the files ' +
'in $HOME/Library/MobileDevice/Provisioning Profiles')
parser.add_argument(
'--mobileprovision-list',
'-M',
action=FileListAction,
dest='mobileprovision_files',
help='path to a file containing a list of mobileprovision files to ' +
'use (this will behave as each "-m $line" was passsed for each line ' +
'in that file)')
@staticmethod
def _Execute(args):
provisioning_profile_info = {}
provisioning_profile = FindProvisioningProfile(args.mobileprovision_files,
args.bundle_id, False)
for key in ('team_identifier', 'name', 'path'):
if provisioning_profile:
provisioning_profile_info[key] = getattr(provisioning_profile, key)
else:
provisioning_profile_info[key] = ''
print(json.dumps(provisioning_profile_info))
def Main():
codecs.lookup('utf-8')
parser = argparse.ArgumentParser('codesign iOS bundles')
subparsers = parser.add_subparsers()
actions = [
CodeSignBundleAction,
CodeSignFileAction,
GenerateEntitlementsAction,
FindProvisioningProfileAction,
]
for action in actions:
action.Register(subparsers)
args = parser.parse_args()
args.func(args)
if __name__ == '__main__':
sys.exit(Main())