120 lines
4 KiB
Python
Executable file
120 lines
4 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Extract signing entitlements from an Apple provisioning profile."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import plistlib
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
PROFILE_REQUIRED_KEYS = {
|
|
"application-identifier",
|
|
"beta-reports-active",
|
|
"com.apple.developer.team-identifier",
|
|
"com.apple.developer.default-data-protection",
|
|
"keychain-access-groups",
|
|
}
|
|
|
|
|
|
def decode_profile(path: Path) -> dict:
|
|
commands = [
|
|
["security", "cms", "-D", "-i", str(path)],
|
|
["openssl", "smime", "-inform", "DER", "-verify", "-noverify", "-in", str(path)],
|
|
["openssl", "cms", "-inform", "DER", "-verify", "-noverify", "-in", str(path)],
|
|
]
|
|
errors: list[str] = []
|
|
for command in commands:
|
|
try:
|
|
proc = subprocess.run(
|
|
command,
|
|
check=False,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
except FileNotFoundError:
|
|
continue
|
|
if proc.returncode == 0 and proc.stdout:
|
|
return plistlib.loads(proc.stdout)
|
|
errors.append(f"{command[0]} exited {proc.returncode}: {proc.stderr.decode(errors='replace').strip()}")
|
|
raise RuntimeError(f"unable to decode provisioning profile {path}: {'; '.join(errors)}")
|
|
|
|
|
|
def load_entitlements_template(path: Path) -> dict:
|
|
value = plistlib.loads(path.read_bytes())
|
|
if not isinstance(value, dict):
|
|
raise RuntimeError(f"entitlements template is not a dictionary: {path}")
|
|
return value
|
|
|
|
|
|
def _has_build_setting(value: object) -> bool:
|
|
if isinstance(value, str):
|
|
return "$(" in value
|
|
if isinstance(value, list):
|
|
return any(_has_build_setting(item) for item in value)
|
|
if isinstance(value, dict):
|
|
return any(_has_build_setting(item) for item in value.values())
|
|
return False
|
|
|
|
|
|
def _filter_list(template_value: list, profile_value: object) -> list:
|
|
if not isinstance(profile_value, list):
|
|
return template_value
|
|
profile_set = set(profile_value)
|
|
filtered = [item for item in template_value if item in profile_set]
|
|
return filtered
|
|
|
|
|
|
def filter_entitlements(profile_entitlements: dict, template_entitlements: dict) -> dict:
|
|
filtered = {
|
|
key: profile_entitlements[key]
|
|
for key in PROFILE_REQUIRED_KEYS
|
|
if key in profile_entitlements
|
|
}
|
|
|
|
for key, template_value in template_entitlements.items():
|
|
if key not in profile_entitlements:
|
|
print(
|
|
f"warning: entitlement {key} requested by template but absent from provisioning profile; omitting",
|
|
file=sys.stderr,
|
|
)
|
|
continue
|
|
|
|
profile_value = profile_entitlements[key]
|
|
if _has_build_setting(template_value):
|
|
filtered[key] = profile_value
|
|
elif isinstance(template_value, list):
|
|
filtered[key] = _filter_list(template_value, profile_value)
|
|
else:
|
|
filtered[key] = template_value
|
|
|
|
return dict(sorted(filtered.items()))
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("profile", type=Path)
|
|
parser.add_argument("--output", "-o", type=Path, required=True)
|
|
parser.add_argument(
|
|
"--template",
|
|
type=Path,
|
|
help="Optional entitlements template used to filter profile entitlements for a target.",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
profile = decode_profile(args.profile)
|
|
entitlements = profile.get("Entitlements")
|
|
if not isinstance(entitlements, dict):
|
|
raise RuntimeError(f"profile has no Entitlements dictionary: {args.profile}")
|
|
|
|
if args.template:
|
|
entitlements = filter_entitlements(entitlements, load_entitlements_template(args.template))
|
|
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_bytes(plistlib.dumps(entitlements, fmt=plistlib.FMT_XML))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|