#!/usr/bin/env python3 """ ReBreak Unsupervised Sideload Profile Generator ================================================ Generiert pro-User .mobileconfig mit frischen UUIDs + individuellem RemovalPassword. Validiert das Resultat via `plutil -lint`. KEIN Signing-Step (User-Entscheidung 2026-05-26: unsigned ausliefern). iOS zeigt "Nicht überprüft" rot beim Install, akzeptabel für DiGA-Pilot. Usage: python3 generate-unsupervised-profile.py \\ --removal-password 482915 \\ --org "ReBreak GmbH" \\ --output /tmp/rebreak-schutz-user-abc123.mobileconfig python3 generate-unsupervised-profile.py --batch users.csv --output-dir /tmp/profiles/ CSV-Format für --batch: user_id,removal_password abc123,482915 def456,719204 """ import argparse import csv import plistlib import subprocess import sys import uuid from pathlib import Path # Template-Quelldatei — wird gelesen, UUIDs + RemovalPassword werden ersetzt. TEMPLATE_PATH = Path(__file__).parent / "rebreak-iphone-unsupervised-sideload.mobileconfig" def generate_profile(removal_password: str, org_name: str = "ReBreak") -> bytes: """ Lädt das Template, generiert frische UUIDs für alle PayloadUUID-Felder, ersetzt RemovalPassword + PayloadOrganization, returnt finale plist als bytes. """ with open(TEMPLATE_PATH, "rb") as f: plist = plistlib.load(f) # Top-Level plist["PayloadUUID"] = str(uuid.uuid4()).upper() plist["PayloadOrganization"] = org_name plist["RemovalPassword"] = removal_password # Sub-Payloads: jeder kriegt eine frische UUID for sub in plist.get("PayloadContent", []): sub["PayloadUUID"] = str(uuid.uuid4()).upper() return plistlib.dumps(plist, fmt=plistlib.FMT_XML) def validate_plist(profile_bytes: bytes, path: Path) -> None: """ Validiert via macOS plutil -lint. Schreibt temporär, lintet, löscht. Wirft Exception bei Lint-Fehlern. """ path.write_bytes(profile_bytes) result = subprocess.run( ["plutil", "-lint", str(path)], capture_output=True, text=True, ) if result.returncode != 0: raise RuntimeError(f"plutil -lint failed: {result.stdout} {result.stderr}") def write_profile(removal_password: str, org_name: str, output_path: Path) -> None: profile = generate_profile(removal_password=removal_password, org_name=org_name) output_path.parent.mkdir(parents=True, exist_ok=True) validate_plist(profile, output_path) print(f"✓ {output_path} (removal-PIN: {removal_password})") def batch_from_csv(csv_path: Path, output_dir: Path, org_name: str) -> None: with open(csv_path) as f: reader = csv.DictReader(f) for row in reader: user_id = row["user_id"].strip() removal_pin = row["removal_password"].strip() output_path = output_dir / f"rebreak-schutz-{user_id}.mobileconfig" write_profile(removal_pin, org_name, output_path) def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--removal-password", help="6+ digit PIN für Profil-Removal (einzelner User)") parser.add_argument("--org", default="ReBreak", help="PayloadOrganization-Name") parser.add_argument("--output", help="Output-Pfad (einzelner User)") parser.add_argument("--batch", help="CSV-Datei: user_id,removal_password") parser.add_argument("--output-dir", help="Output-Verzeichnis für --batch") args = parser.parse_args() if args.batch: if not args.output_dir: parser.error("--batch braucht --output-dir") batch_from_csv(Path(args.batch), Path(args.output_dir), args.org) else: if not (args.removal_password and args.output): parser.error("--removal-password und --output sind required (oder --batch nutzen)") if not args.removal_password.isdigit() or len(args.removal_password) < 6: parser.error("--removal-password muss 6+ Ziffern haben") write_profile(args.removal_password, args.org, Path(args.output)) return 0 if __name__ == "__main__": sys.exit(main())