chahinebrini 01374c426e feat(supervise-magic): TechLockdown-clone v1 — supervise iPhones without erase
Implementiert eigenen MobileBackup2-Restore-Trick zur Supervision-Übernahme
von aktivierten iOS-Geräten ohne Factory-Reset. Foundation für DiGA-Phase-G
Lock-Layer-Stack (non-removable Apps, non-removable Profiles, OnDemand-VPN-
Toggle-Lock) auf Consumer-iPhones.

Verifiziert end-to-end auf:
- iPhone Air (iPhone18,4, iOS 26.5): TL→ReBreak re-supervise 
- Olfa iPhone 14 Pro (iPhone15,3, iOS 26.4.2): TL→ReBreak re-supervise 

Key empirische Findings:
1. Find-My-iPhone MUSS off sein (ErrorCode 211 sonst)
   → Stolen Device Protection (SDP) zwingt FMI an seit iOS 17.3+
2. SupervisorHostCertificates DARF NICHT in CloudConfigurationDetails sein
   für fresh-supervise auf activated unsupervised devices (sonst partial-apply)
3. MCInstall.SetCloudConfiguration firet 14002 auf allen activated devices
   → MobileBackup2-Restore-Trick ist der einzige Weg
4. TL's extracted-embed-bytes != TL's wire-output (Runtime-Mutation)
   → Verbatim-Kopieren reicht nicht

Reverse-Engineering basiert komplett auf:
- Apple's public protocol docs (devicemanagement, mobilebackup2 schemas)
- libimobiledevice (open-source reference impl)
- TL public-distributed binary (interop-RE, legal per US-DMCA-1201 + EU-2009/24)

Structure:
  cmd/supervise/         — main CLI (check, cloud-config, supervise, cert-info, unsupervise)
  cmd/dump-artifacts/    — diagnostic helper (no device needed)
  cmd/usbmux-proxy/      — MITM-proxy for TL-traffic-capture (debug)
  cmd/tl-patcher/        — patches TL's hard-coded usbmuxd path (debug)
  internal/dlmessage/    — DLMessage wire-protocol (4-byte BE length + plist)
  internal/mobilebackup2/— mobilebackup2-service impl (BaseVersionExchange,
                          Hello, Restore, ServeFiles + TL-extracted templates)
  internal/cloudconfig/  — CloudConfigurationDetails.plist builder (cert-less,
                          25 keys matching TL's runtime-output)
  internal/cert/         — auto-gen + persist supervisor-cert in ~/.rebreak-supervise/
  internal/mcinstall/    — MCInstall.GetCloudConfiguration für state-checks
  internal/device/       — go-ios DeviceEntry wrapper
  internal/afclock/      — AFC sync-lock auf /com.apple.itunes.lock_sync
  internal/notification_proxy/ — PostNotification (syncWillStart/etc)
  internal/preflight/    — FMI/Activation/OS-version pre-checks
  internal/supervise/    — End-to-end Flow-Orchestrierung (MobileBackup2 default,
                          MCInstall via REBREAK_FORCE_MCINSTALL=1)

Pending für volle Productization (Phase G):
- Fresh-supervise direkt-empirisch auf truly-unsupervised iPhone testen
  (heute Nacht nur durch Inferenz aus TL-Verhalten gestützt)
- Auto-MDM-enroll-Step nach Supervise (ConfigurationURL oder cfgutil-style)
- DiGA-Onboarding-Flow + Lyra-Coach für FMI/SDP-Disable
- Multi-Device-Validation (Modelle, iOS-Versionen)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 01:55:10 +02:00

333 lines
9.8 KiB
Go

// File-server-Loop für MobileBackup2 Restore-Mode.
//
// Während Restore drives das iPhone das gespräch:
// - Es schickt DLMessageDownloadFiles mit Filenamen
// - Es erwartet Datei-Content via custom binary chunks (NICHT DLMessage!)
//
// Wire-Format für File-Transfer (auf demselben Socket wie DLMessage):
//
// → [4-byte filename-length big-endian][filename UTF-8]
// → [4-byte code=12 DATA][4-byte 0 length] (start)
// → [4-byte code=12 DATA][4-byte chunk-length][chunk-data] (repeat)
// → [4-byte code=0 DONE][4-byte 0]
//
// Nach allen Files:
// → [4-byte 0 — no more files]
// → DLMessageStatusResponse [0, "___EmptyParameterString___", {}]
//
// Codes:
//
// CODE_FILE_DONE = 0 (end-of-file marker)
// CODE_FILE_DATA = 12 (data chunk)
// CODE_FILE_ERROR = 6 (error)
package mobilebackup2
import (
"encoding/binary"
"errors"
"fmt"
"io"
"github.com/raynis/rebreak-supervise-magic/internal/dlmessage"
)
// 1-byte chunk-codes (matched libimobiledevice's CODE_FILE_DATA etc).
const (
fileCodeDone byte = 0x00 // CODE_SUCCESS — end of file
fileCodeData byte = 0x0c // CODE_FILE_DATA — chunk
fileCodeError byte = 0x06 // CODE_ERROR_LOCAL — file not found / error
// Apple sentinel: status-response "no error".
emptyParameterString = "___EmptyParameterString___"
)
// FileProvider liefert Datei-Content für einen relativen Pfad.
// Returnt (content, true) wenn vorhanden, ([], false) sonst.
type FileProvider func(relpath string) ([]byte, bool)
// ServeFiles fängt das iPhone-driven file-serve-loop ab.
// Loopt bis DLMessageDisconnect oder Error.
//
// Files werden via `provider` geliefert. Wenn provider eine angefragte
// Datei nicht hat: Server returnt CODE_FILE_ERROR.
//
// onProgress wird bei jedem Mess-event aufgerufen (für UI/Log).
func (c *Client) ServeFiles(provider FileProvider, onProgress func(event string, info string)) error {
if onProgress == nil {
onProgress = func(string, string) {}
}
for {
t, args, err := c.dl.Receive()
if err != nil {
return fmt.Errorf("serve: receive: %w", err)
}
switch t {
case dlmessage.TypeProcessMessage:
if len(args) == 0 {
continue
}
dict, ok := args[0].(map[string]interface{})
if !ok {
continue
}
msgName, _ := dict["MessageName"].(string)
// 2026-05-28 DEBUG: log full dict to understand what iPhone sends
onProgress("ProcessMessage", fmt.Sprintf("%s | full=%v", msgName, dict))
if msgName == "DLMessageDisconnect" || msgName == "Disconnect" {
return nil
}
// "Response" = iPhone signals operation completed — NICHT antworten,
// nur weiter loopen + auf nächste Message warten.
if msgName == "Response" {
continue
}
// Andere Sub-Messages — Status OK respond.
if err := c.sendStatusOK(); err != nil {
return err
}
case dlmessage.TypeStatusResponse:
// Device-Status — wir loggen + machen weiter
onProgress("StatusResponse", fmt.Sprintf("%v", args))
// Falls error: abbrechen
if len(args) > 0 {
if code, ok := args[0].(uint64); ok && code != 0 {
return fmt.Errorf("serve: device error code=%d: %v", code, args)
}
}
case dlmessage.TypeGetFreeDiskSpace:
// Device fragt nach freiem Speicher — wir behaupten Schubladen voll Platz
onProgress("GetFreeDiskSpace", "")
// Antwort: [0, errStr, freeSpaceUint64]
if err := c.dl.Send(dlmessage.TypeStatusResponse, uint64(0), emptyParameterString, uint64(1<<40)); err != nil {
return err
}
case dlmessage.TypeContentsOfDirectory:
// Device möchte Verzeichnis listen — wir antworten mit empty dict
dirname := ""
if len(args) > 0 {
dirname, _ = args[0].(string)
}
onProgress("ContentsOfDirectory", dirname)
// Antwort: status [0, "", {}]
if err := c.dl.Send(dlmessage.TypeStatusResponse, uint64(0), emptyParameterString, map[string]interface{}{}); err != nil {
return err
}
case dlmessage.TypeCreateDirectory, dlmessage.TypeMoveItems, dlmessage.TypeRemoveItems, dlmessage.TypeCopyItem:
onProgress(t, "")
if err := c.sendStatusOK(); err != nil {
return err
}
case dlmessage.TypeDownloadFiles:
// Device fragt files an — args[0] ist []string
var fileList []string
if len(args) > 0 {
if arr, ok := args[0].([]interface{}); ok {
for _, item := range arr {
if s, ok := item.(string); ok {
fileList = append(fileList, s)
}
}
}
}
onProgress("DownloadFiles", fmt.Sprintf("count=%d", len(fileList)))
if err := c.sendFiles(fileList, provider, onProgress); err != nil {
return fmt.Errorf("serve: send files: %w", err)
}
case dlmessage.TypeUploadFiles:
// iPhone will UNS files uploaden (sein current state).
// Wir ACK + receive raw upload-stream.
onProgress("UploadFiles", "device uploading")
if err := c.sendStatusOK(); err != nil {
return err
}
if err := c.receiveFiles(onProgress); err != nil {
return fmt.Errorf("serve: receive files: %w", err)
}
case dlmessage.TypeDisconnect:
onProgress("Disconnect", "explicit")
return nil
default:
// unbekannter Typ — log + status OK
onProgress("Unknown", t)
if err := c.sendStatusOK(); err != nil {
return err
}
}
}
}
func (c *Client) sendStatusOK() error {
return c.dl.Send(dlmessage.TypeStatusResponse, uint64(0), emptyParameterString, map[string]interface{}{})
}
// sendFiles antwortet auf DLMessageDownloadFiles. Wire-format aus libimobiledevice
// mb2_handle_send_file verifiziert:
//
// per file:
// [4-byte BE filename-length][filename UTF-8]
// if file exists:
// per chunk: [4-byte BE (1+chunk-size)][1-byte 0x0c CODE_DATA][chunk-data]
// end-of-file: [4-byte BE 1][1-byte 0x00 CODE_SUCCESS]
// else:
// [4-byte BE 1][1-byte 0x06 CODE_ERROR_LOCAL]
//
// after all files:
// [4-byte BE 0] (terminator = "no more files")
// DLMessageStatusResponse [0, "___EmptyParameterString___", {<errors>}]
func (c *Client) sendFiles(fileList []string, provider FileProvider, onProgress func(string, string)) error {
for _, fname := range fileList {
content, ok := provider(fname)
onProgress("send-file", fmt.Sprintf("%s (%d bytes, found=%v)", fname, len(content), ok))
// filename header
if err := c.writeRawU32(uint32(len(fname))); err != nil {
return err
}
if err := c.deviceConn.Send([]byte(fname)); err != nil {
return fmt.Errorf("write filename: %w", err)
}
if !ok {
// ERROR: [length=1][CODE_ERROR_LOCAL]
if err := c.writeRawU32(1); err != nil {
return err
}
if err := c.deviceConn.Send([]byte{fileCodeError}); err != nil {
return err
}
continue
}
// DATA chunks
const maxChunk = 32 * 1024
for offset := 0; offset < len(content); offset += maxChunk {
end := offset + maxChunk
if end > len(content) {
end = len(content)
}
chunk := content[offset:end]
// [length=1+chunk][CODE_DATA][chunk]
if err := c.writeRawU32(uint32(1 + len(chunk))); err != nil {
return err
}
if err := c.deviceConn.Send([]byte{fileCodeData}); err != nil {
return err
}
if err := c.deviceConn.Send(chunk); err != nil {
return err
}
}
// END-OF-FILE: [length=1][CODE_SUCCESS]
if err := c.writeRawU32(1); err != nil {
return err
}
if err := c.deviceConn.Send([]byte{fileCodeDone}); err != nil {
return err
}
}
// Terminator: [length=0]
if err := c.writeRawU32(0); err != nil {
return err
}
// Final status
return c.sendStatusOK()
}
func (c *Client) writeRawU32(v uint32) error {
buf := make([]byte, 4)
binary.BigEndian.PutUint32(buf, v)
return c.deviceConn.Send(buf)
}
// receiveFiles empfängt iPhone's file-upload-stream nach DLMessageUploadFiles.
// Wire-format mirror von sendFiles (gleiche frames, andere Richtung):
//
// per file:
// [4-byte BE path-len][path]
// per chunk: [4-byte BE (1+chunk-size)][1-byte code][data]
// end-of-file: [4-byte BE 1][1-byte 0x00 SUCCESS]
// terminator: [4-byte BE 0]
//
// Wir discardieren den Inhalt — iPhone's "backup-of-backup" interessiert uns nicht,
// nur dass wir den Stream sauber durchlesen damit das Protokoll weitergeht.
func (c *Client) receiveFiles(onProgress func(string, string)) error {
reader := c.deviceConn.Reader()
totalFiles := 0
totalBytes := uint64(0)
for {
// path-length-prefix
var pathLenBuf [4]byte
if _, err := io.ReadFull(reader, pathLenBuf[:]); err != nil {
return fmt.Errorf("read path-len: %w", err)
}
pathLen := binary.BigEndian.Uint32(pathLenBuf[:])
if pathLen == 0 {
// terminator — no more files
onProgress("upload-done", fmt.Sprintf("%d files, %d bytes total", totalFiles, totalBytes))
return nil
}
if pathLen > 65536 {
return fmt.Errorf("upload-path too large: %d", pathLen)
}
// path-bytes
pathBuf := make([]byte, pathLen)
if _, err := io.ReadFull(reader, pathBuf); err != nil {
return fmt.Errorf("read path: %w", err)
}
path := string(pathBuf)
totalFiles++
// chunks
fileBytes := uint64(0)
for {
var chunkLenBuf [4]byte
if _, err := io.ReadFull(reader, chunkLenBuf[:]); err != nil {
return fmt.Errorf("read chunk-len for %s: %w", path, err)
}
chunkLen := binary.BigEndian.Uint32(chunkLenBuf[:])
if chunkLen == 0 {
// some implementations send zero-length as terminator
break
}
// 1-byte code
var code [1]byte
if _, err := io.ReadFull(reader, code[:]); err != nil {
return fmt.Errorf("read chunk-code: %w", err)
}
dataLen := chunkLen - 1
if dataLen > 0 {
discard := make([]byte, dataLen)
if _, err := io.ReadFull(reader, discard); err != nil {
return fmt.Errorf("read chunk-data: %w", err)
}
fileBytes += uint64(dataLen)
}
// code 0x00 (CODE_SUCCESS) = end-of-file
if code[0] == fileCodeDone {
break
}
}
totalBytes += fileBytes
onProgress("upload-file", fmt.Sprintf("%s (%d bytes)", path, fileBytes))
}
}
// ErrServeAborted ist ein Sentinel den FileProvider zurückgeben kann um Loop
// zu beenden (z.B. bei Disconnect-Trigger).
var ErrServeAborted = errors.New("serve aborted")