This commit improves the `find_mas_provisioning_profile.sh` script by expanding the search for provisioning profiles to include both `.provisionprofile` and `.mobileprovision` files. It also introduces a new function to print SHA-1 values for identities, helping to avoid ambiguity when multiple identities share the same display name. Additionally, the `check_signing_identities.sh` script is updated to provide clearer recommendations for using SHA-1 hashes with codesigning and installer identities, enhancing the overall usability and clarity for developers working with macOS builds.
211 lines
6.6 KiB
Python
211 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import datetime as dt
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import plistlib
|
|
|
|
|
|
@dataclass
|
|
class ProfileInfo:
|
|
path: Path
|
|
uuid: str
|
|
name: str
|
|
team_id: str
|
|
expiration: Optional[dt.datetime]
|
|
app_id: str
|
|
platforms: List[str]
|
|
|
|
|
|
def run(cmd: List[str]) -> Tuple[int, bytes, bytes]:
|
|
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
out, err = p.communicate()
|
|
return p.returncode, out, err
|
|
|
|
|
|
def decode_profile_plist_bytes(profile_path: Path) -> Optional[bytes]:
|
|
# Provisioning profiles are typically CMS/PKCS#7 SignedData blobs whose payload is a plist.
|
|
# However, some tools store them as plain XML plists already. Also, LibreSSL/OpenSSL behavior
|
|
# differs: LibreSSL usually requires an explicit '-verify' to emit the embedded content.
|
|
data = profile_path.read_bytes()
|
|
|
|
# Fast path: already a plist (XML).
|
|
if b"<plist" in data:
|
|
return data
|
|
|
|
# Decode CMS/PKCS7 to extract embedded plist payload.
|
|
# Try a small matrix of commands/inform formats for compatibility.
|
|
candidates: List[List[str]] = []
|
|
for inform in ("DER", "PEM"):
|
|
candidates.append(["/usr/bin/openssl", "cms", "-verify", "-noverify", "-inform", inform, "-in", str(profile_path)])
|
|
candidates.append(["/usr/bin/openssl", "smime", "-verify", "-noverify", "-inform", inform, "-in", str(profile_path)])
|
|
|
|
for cmd in candidates:
|
|
rc, out, _err = run(cmd)
|
|
if rc == 0 and b"<plist" in out:
|
|
return out
|
|
|
|
return None
|
|
|
|
|
|
def parse_plist(plist_bytes: bytes) -> Dict[str, Any]:
|
|
return plistlib.loads(plist_bytes)
|
|
|
|
|
|
def iso(dt_obj: Optional[dt.datetime]) -> str:
|
|
if not dt_obj:
|
|
return "(unknown)"
|
|
# Force UTC-ish display if tz-aware, otherwise as-is.
|
|
try:
|
|
return dt_obj.isoformat().replace("+00:00", "Z")
|
|
except Exception:
|
|
return str(dt_obj)
|
|
|
|
|
|
def safe_str(v: Any) -> str:
|
|
if v is None:
|
|
return ""
|
|
if isinstance(v, bytes):
|
|
try:
|
|
return v.decode("utf-8", errors="replace")
|
|
except Exception:
|
|
return repr(v)
|
|
return str(v)
|
|
|
|
|
|
def profile_info_from_plist(path: Path, p: Dict[str, Any]) -> ProfileInfo:
|
|
uuid = safe_str(p.get("UUID", "")) or "(unknown)"
|
|
name = safe_str(p.get("Name", "")) or "(unknown)"
|
|
team_ids = p.get("TeamIdentifier") or []
|
|
team_id = safe_str(team_ids[0]) if isinstance(team_ids, list) and team_ids else ""
|
|
if not team_id:
|
|
prefixes = p.get("ApplicationIdentifierPrefix") or []
|
|
team_id = safe_str(prefixes[0]) if isinstance(prefixes, list) and prefixes else "(unknown)"
|
|
exp = p.get("ExpirationDate")
|
|
expiration = exp if isinstance(exp, dt.datetime) else None
|
|
ent = p.get("Entitlements") or {}
|
|
app_id = safe_str(ent.get("application-identifier") or ent.get("com.apple.application-identifier") or "") or "(unknown)"
|
|
platforms = p.get("Platform") or []
|
|
if isinstance(platforms, str):
|
|
platforms = [platforms]
|
|
platforms = [safe_str(x) for x in platforms if x is not None]
|
|
return ProfileInfo(path=path, uuid=uuid, name=name, team_id=team_id or "(unknown)", expiration=expiration, app_id=app_id, platforms=platforms)
|
|
|
|
|
|
def score(profile: ProfileInfo, bundle_id: str, now: dt.datetime) -> Tuple[int, str]:
|
|
# Prefer non-expired.
|
|
if profile.expiration and profile.expiration < now:
|
|
return (-1, "expired")
|
|
|
|
score = 0
|
|
reason = []
|
|
|
|
# Prefer exact app id match TEAMID.bundle_id
|
|
if profile.team_id != "(unknown)" and profile.app_id != "(unknown)":
|
|
exact = f"{profile.team_id}.{bundle_id}"
|
|
if profile.app_id == exact:
|
|
score += 100
|
|
reason.append(f"exact {profile.app_id}")
|
|
elif profile.app_id.endswith(f".{bundle_id}"):
|
|
score += 60
|
|
reason.append(f"endswith {profile.app_id}")
|
|
elif "*" in profile.app_id and profile.app_id.startswith(f"{profile.team_id}."):
|
|
score += 40
|
|
reason.append(f"wildcard {profile.app_id}")
|
|
|
|
# Heuristic: name suggests MAS.
|
|
n = profile.name.lower()
|
|
if "mac app store" in n or "app store" in n or "appstore" in n:
|
|
score += 5
|
|
reason.append("name looks like MAS")
|
|
|
|
# Prefer macOS platform if present.
|
|
plats = [p.lower() for p in profile.platforms]
|
|
if any("macos" in p for p in plats):
|
|
score += 2
|
|
reason.append("platform macos")
|
|
|
|
return (score, ", ".join(reason) if reason else "")
|
|
|
|
|
|
def find_profiles() -> List[Path]:
|
|
dirs = [
|
|
Path.home() / "Library" / "Developer" / "Xcode" / "UserData" / "Provisioning Profiles",
|
|
Path.home() / "Library" / "MobileDevice" / "Provisioning Profiles",
|
|
]
|
|
out: List[Path] = []
|
|
for d in dirs:
|
|
if not d.is_dir():
|
|
continue
|
|
for p in d.iterdir():
|
|
if p.is_file() and (p.name.endswith(".provisionprofile") or p.name.endswith(".mobileprovision")):
|
|
out.append(p)
|
|
return sorted(out)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--bundle-id", required=True)
|
|
args = ap.parse_args()
|
|
bundle_id = args.bundle_id
|
|
|
|
if not Path("/usr/bin/openssl").exists():
|
|
print("Error: /usr/bin/openssl not found.", file=sys.stderr)
|
|
return 1
|
|
|
|
candidates = find_profiles()
|
|
if not candidates:
|
|
print("No provisioning profiles found in common locations.", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"Scanning {len(candidates)} provisioning profile(s) for bundle id: {bundle_id}")
|
|
print()
|
|
print(f"{'No.':<4} {'UUID':<36} {'TeamID':<10} {'Expires':<25} {'AppID':<45} Path")
|
|
print(f"{'-'*4} {'-'*36} {'-'*10} {'-'*25} {'-'*45} ----")
|
|
|
|
infos: List[ProfileInfo] = []
|
|
for i, p in enumerate(candidates, start=1):
|
|
plist_bytes = decode_profile_plist_bytes(p)
|
|
if not plist_bytes:
|
|
continue
|
|
try:
|
|
pl = parse_plist(plist_bytes)
|
|
info = profile_info_from_plist(p, pl)
|
|
infos.append(info)
|
|
print(f"{i:<4} {info.uuid:<36} {info.team_id:<10} {iso(info.expiration):<25} {info.app_id:<45} {info.path}")
|
|
except Exception:
|
|
continue
|
|
|
|
if not infos:
|
|
print("\nCould not decode any provisioning profiles with openssl cms.", file=sys.stderr)
|
|
return 2
|
|
|
|
now = dt.datetime.now(dt.timezone.utc)
|
|
best: Optional[Tuple[int, str, ProfileInfo]] = None
|
|
for info in infos:
|
|
sc, why = score(info, bundle_id, now)
|
|
if best is None or sc > best[0]:
|
|
best = (sc, why, info)
|
|
|
|
print()
|
|
if best is None or best[0] <= 0:
|
|
print(f"Could not confidently auto-select a profile for {bundle_id}.", file=sys.stderr)
|
|
print("Pick the profile whose AppID is TEAMID.<bundle-id> and is a macOS Mac App Store profile.", file=sys.stderr)
|
|
return 2
|
|
|
|
_, why, info = best
|
|
print("Recommended profile:")
|
|
print(f" {info.path}")
|
|
print(f" reason: {why}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|
|
|