From e66b7c027b5d74427fd1b6ad7453f61141ade40e Mon Sep 17 00:00:00 2001 From: doronz Date: Thu, 6 Jul 2023 17:19:32 +0300 Subject: [PATCH] add `remote` services support --- pymobiledevice3/__main__.py | 6 +- pymobiledevice3/cli/remote.py | 61 +++ pymobiledevice3/exceptions.py | 5 + pymobiledevice3/remote/__init__.py | 0 pymobiledevice3/remote/bonjour.py | 40 ++ .../remote/core_device_tunnel_service.py | 472 ++++++++++++++++++ .../remote/remote_pairing_service.py | 16 + .../remote/remote_service_discovery.py | 29 ++ pymobiledevice3/remote/remotexpc.py | 331 ++++++++++++ pymobiledevice3/remote/research.md | 84 ++++ pyproject.toml | 3 +- requirements.txt | 2 + 12 files changed, 1047 insertions(+), 2 deletions(-) create mode 100644 pymobiledevice3/cli/remote.py create mode 100644 pymobiledevice3/remote/__init__.py create mode 100644 pymobiledevice3/remote/bonjour.py create mode 100644 pymobiledevice3/remote/core_device_tunnel_service.py create mode 100644 pymobiledevice3/remote/remote_pairing_service.py create mode 100644 pymobiledevice3/remote/remote_service_discovery.py create mode 100644 pymobiledevice3/remote/remotexpc.py create mode 100644 pymobiledevice3/remote/research.md diff --git a/pymobiledevice3/__main__.py b/pymobiledevice3/__main__.py index c3aa7a4c1..a2e5f2976 100644 --- a/pymobiledevice3/__main__.py +++ b/pymobiledevice3/__main__.py @@ -23,6 +23,7 @@ from pymobiledevice3.cli.processes import cli as ps_cli from pymobiledevice3.cli.profile import cli as profile_cli from pymobiledevice3.cli.provision import cli as provision_cli +from pymobiledevice3.cli.remote import cli as remote_cli from pymobiledevice3.cli.restore import cli as restore_cli from pymobiledevice3.cli.springboard import cli as springboard_cli from pymobiledevice3.cli.syslog import cli as syslog_cli @@ -35,7 +36,9 @@ coloredlogs.install(level=logging.INFO) +logging.getLogger('quic').disabled = True logging.getLogger('asyncio').disabled = True +logging.getLogger('zeroconf').disabled = True logging.getLogger('parso.cache').disabled = True logging.getLogger('parso.cache.pickle').disabled = True logging.getLogger('parso.python.diff').disabled = True @@ -50,7 +53,8 @@ def cli(): cli_commands = click.CommandCollection(sources=[ developer_cli, mounter_cli, apps_cli, profile_cli, lockdown_cli, diagnostics_cli, syslog_cli, pcap_cli, crash_cli, afc_cli, ps_cli, notification_cli, usbmux_cli, power_assertion_cli, springboard_cli, - provision_cli, backup_cli, restore_cli, activation_cli, companion_cli, webinspector_cli, amfi_cli, bonjour_cli + provision_cli, backup_cli, restore_cli, activation_cli, companion_cli, webinspector_cli, amfi_cli, bonjour_cli, + remote_cli ]) cli_commands.context_settings = dict(help_option_names=['-h', '--help']) try: diff --git a/pymobiledevice3/cli/remote.py b/pymobiledevice3/cli/remote.py new file mode 100644 index 000000000..ca1870549 --- /dev/null +++ b/pymobiledevice3/cli/remote.py @@ -0,0 +1,61 @@ +import asyncio +import logging + +import click +from cryptography.hazmat.primitives.asymmetric import rsa + +from pymobiledevice3.cli.cli_common import print_json +from pymobiledevice3.remote.bonjour import get_iphone_address +from pymobiledevice3.remote.core_device_tunnel_service import create_core_device_tunnel_service +from pymobiledevice3.remote.remote_service_discovery import RSD_PORT, RemoteServiceDiscoveryService + +logger = logging.getLogger(__name__) + + +@click.group() +def cli(): + """ remote cli """ + pass + + +@cli.group('remote') +def remote_cli(): + """ remote options """ + pass + + +@remote_cli.command('rsd-info') +@click.option('--color/--no-color', default=True) +def rsd_info(color: bool): + """ show info extracted from RSD peer """ + hostname = asyncio.run(get_iphone_address()) + + with RemoteServiceDiscoveryService((hostname, RSD_PORT)) as rsd: + print_json(rsd.peer_info, colored=color) + + +@remote_cli.command('create-listener') +@click.option('-p', '--protocol', type=click.Choice(['quic', 'udp'])) +@click.option('--color/--no-color', default=True) +def create_listener(protocol: str, color: bool): + """ start a remote listener """ + hostname = asyncio.run(get_iphone_address()) + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + with RemoteServiceDiscoveryService((hostname, RSD_PORT)) as rsd: + with create_core_device_tunnel_service(rsd, autopair=True) as service: + print_json(service.create_listener(private_key, protocol=protocol), colored=color) + + +@remote_cli.command('start-quic-tunnel') +@click.option('--color/--no-color', default=True) +def start_quic_tunnel(color: bool): + """ start quic tunnel """ + logger.critical('This is a WIP command. Will only print the required parameters for the quic connection') + + hostname = asyncio.run(get_iphone_address()) + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + with RemoteServiceDiscoveryService((hostname, RSD_PORT)) as rsd: + with create_core_device_tunnel_service(rsd, autopair=True) as service: + print_json(asyncio.run(service.start_quic_tunnel(private_key)), colored=color) diff --git a/pymobiledevice3/exceptions.py b/pymobiledevice3/exceptions.py index b394a0f50..64a16a65c 100644 --- a/pymobiledevice3/exceptions.py +++ b/pymobiledevice3/exceptions.py @@ -141,6 +141,11 @@ class ConnectionTerminatedError(PyMobileDevice3Exception): pass +class StreamClosedError(ConnectionTerminatedError): + """ Raise trying to send a message on a closed stream. """ + pass + + class WebInspectorNotEnabledError(PyMobileDevice3Exception): """ Raise when Web Inspector is not enabled. """ pass diff --git a/pymobiledevice3/remote/__init__.py b/pymobiledevice3/remote/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pymobiledevice3/remote/bonjour.py b/pymobiledevice3/remote/bonjour.py new file mode 100644 index 000000000..2a971da64 --- /dev/null +++ b/pymobiledevice3/remote/bonjour.py @@ -0,0 +1,40 @@ +import asyncio +from socket import AF_INET6, inet_ntop + +from ifaddr import get_adapters +from zeroconf import ServiceBrowser, ServiceListener, Zeroconf +from zeroconf.const import _TYPE_AAAA + + +class RemotedListener(ServiceListener): + def __init__(self): + super().__init__() + self.is_finished = asyncio.Event() + + def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: + if name == 'ncm._remoted._tcp.local.': + service_info = zc.get_service_info(type_, name) + records = zc.cache.async_entries_with_name(service_info.server) + for record in records: + if record.type == _TYPE_AAAA: + self.record = record + self.is_finished.set() + + +async def try_get_iphone_address(adapter): + ip = adapter.ips[0].ip[0] + zeroconf = Zeroconf(interfaces=[ip]) + waiter_task = asyncio.create_task(zeroconf.notify_event.wait()) + listener = RemotedListener() + ServiceBrowser(zeroconf, '_remoted._tcp.local.', listener) + await waiter_task + await listener.is_finished.wait() + return inet_ntop(AF_INET6, listener.record.address) + '%' + adapter.nice_name + + +async def get_iphone_address(): + adapters = get_adapters() + adapters = [adapter for adapter in adapters if adapter.ips[0].is_IPv6] + tasks = [asyncio.create_task(try_get_iphone_address(adapter)) for adapter in adapters] + finished, unfinished = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + return list(finished)[0].result() diff --git a/pymobiledevice3/remote/core_device_tunnel_service.py b/pymobiledevice3/remote/core_device_tunnel_service.py new file mode 100644 index 000000000..5f9bcc2ba --- /dev/null +++ b/pymobiledevice3/remote/core_device_tunnel_service.py @@ -0,0 +1,472 @@ +import asyncio +import base64 +import binascii +import hashlib +import json +import logging +import plistlib +from collections import namedtuple +from pathlib import Path +from ssl import VerifyMode +from typing import List, Mapping, Optional, TextIO, cast + +from aioquic.asyncio import QuicConnectionProtocol +from aioquic.asyncio.client import connect +from aioquic.asyncio.protocol import QuicStreamHandler +from aioquic.quic.configuration import QuicConfiguration +from aioquic.quic.connection import QuicConnection +from aioquic.quic.events import QuicEvent, StreamDataReceived +from construct import Const, Container, Enum, GreedyBytes, GreedyRange, Int8ul, Int16ub, Int64ul, Prefixed, Struct +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives._serialization import Encoding, PublicFormat +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from srptools import SRPClientSession, SRPContext +from srptools.constants import PRIME_3072, PRIME_3072_GEN + +from pymobiledevice3.ca import make_cert +from pymobiledevice3.pair_records import create_pairing_records_cache_folder, generate_host_id +from pymobiledevice3.remote.remote_pairing_service import RemotePairingService +from pymobiledevice3.remote.remote_service_discovery import RemoteServiceDiscoveryService +from pymobiledevice3.remote.remotexpc import XpcInt64Type, XpcUInt64Type + +PairingDataComponentType = Enum(Int8ul, + METHOD=0x00, + IDENTIFIER=0x01, + SALT=0x02, + PUBLIC_KEY=0x03, + PROOF=0x04, + ENCRYPTED_DATA=0x05, + STATE=0x06, + ERROR=0x07, + RETRY_DELAY=0x08, + CERTIFICATE=0x09, + SIGNATURE=0x0a, + PERMISSIONS=0x0b, + FRAGMENT_DATA=0x0c, + FRAGMENT_LAST=0x0d, + SESSION_ID=0x0e, + TTL=0x0f, + EXTRA_DATA=0x10, + INFO=0x11, + ACL=0x12, + FLAGS=0x13, + VALIDATION_DATA=0x14, + MFI_AUTH_TOKEN=0x15, + MFI_PRODUCT_TYPE=0x16, + SERIAL_NUMBER=0x17, + MFI_AUTH_TOKEN_UUID=0x18, + APP_FLAGS=0x19, + OWNERSHIP_PROOF=0x1a, + SETUP_CODE_TYPE=0x1b, + PRODUCTION_DATA=0x1c, + APP_INFO=0x1d, + SEPARATOR=0xff) + +TLV = Struct( + 'type' / PairingDataComponentType, + 'data' / Prefixed(Int8ul, GreedyBytes), +) + +TLVBuf = GreedyRange(TLV) + +PairConsentResult = namedtuple('PairConsentResult', 'public_key salt') + +CDTunnelPacket = Struct( + 'magic' / Const(b'CDTunnel'), + 'body' / Prefixed(Int16ub, GreedyBytes), +) + +logger = logging.getLogger(__name__) + + +class RemotePairingTunnel(QuicConnectionProtocol): + def __init__(self, quic: QuicConnection, stream_handler: Optional[QuicStreamHandler] = None): + super().__init__(quic, stream_handler) + self._ack_waiter: Optional[asyncio.Future[None]] = None + + async def request_tunnel_establish(self) -> Mapping: + stream_id = self._quic.get_next_available_stream_id() + + # TODO: understand what this buffer should actually do + self._quic.send_datagram_frame(b'x' * 1024) + + self._quic.send_stream_data(stream_id, self._encode_cdtunnel_packet( + {'type': 'clientHandshakeRequest', 'mtu': 1420})) + + waiter = self._loop.create_future() + self._ack_waiter = waiter + self.transmit() + + return await asyncio.shield(waiter) + + def quic_event_received(self, event: QuicEvent) -> None: + if isinstance(event, StreamDataReceived): + response = json.loads(CDTunnelPacket.parse(event.data).body) + waiter = self._ack_waiter + self._ack_waiter = None + waiter.set_result(response) + + @staticmethod + def _encode_cdtunnel_packet(data: Mapping) -> bytes: + return CDTunnelPacket.build({'body': json.dumps(data).encode()}) + + +class CoreDeviceTunnelService(RemotePairingService): + def __init__(self, rsd: RemoteServiceDiscoveryService): + super().__init__(rsd) + self._sequence_number = 0 + self._encrypted_sequence_number = 0 + self.version = None + self.handshake_info = None + self.x25519_private_key = X25519PrivateKey.generate() + self.ed25519_private_key = Ed25519PrivateKey.generate() + self.identifier = generate_host_id() + self.srp_context = None + self.encryption_key = None + self.signature = None + + def connect(self, autopair: bool = True) -> None: + self.service = self.rsd.connect_to_service('com.apple.internal.dt.coredevice.untrusted.tunnelservice') + self.version = self.service.receive_response()['ServiceVersion'] + + self._attempt_validate_pairing() + if not self._validate_pairing(): + if autopair: + self._pair() + self._init_client_server_main_encryption_keys() + + def create_listener(self, private_key: RSAPrivateKey, protocol: str = 'quic') -> Mapping: + return self._create_listener(private_key, protocol) + + async def start_quic_tunnel(self, private_key: RSAPrivateKey, secrets_log_file: Optional[TextIO] = None) -> Mapping: + parameters = self.create_listener(private_key, protocol='quic') + cert = make_cert(private_key, private_key.public_key()) + configuration = QuicConfiguration(alpn_protocols=['RemotePairingTunnelProtocol'], + is_client=True, + certificate=cert, + private_key=private_key, + verify_mode=VerifyMode.CERT_NONE) + configuration.secrets_log_file = secrets_log_file + + host = self.service.address[0] + port = parameters['port'] + + logger.debug(f'Connecting to {host}:{port}') + async with connect( + host, + port, + configuration=configuration, + create_protocol=RemotePairingTunnel, + ) as client: + logger.debug('quic connected') + client = cast(RemotePairingTunnel, client) + return await client.request_tunnel_establish() + + def save_pair_record(self) -> None: + self.pair_record_path.write_bytes( + plistlib.dumps({ + 'public_key': self.ed25519_private_key.public_key().public_bytes_raw(), + 'private_key': self.ed25519_private_key.private_bytes_raw(), + })) + + @property + def pair_record(self) -> Optional[Mapping]: + if self.pair_record_path.exists(): + return plistlib.loads(self.pair_record_path.read_bytes()) + return None + + @property + def pair_record_path(self) -> Path: + pair_records_cache_directory = create_pairing_records_cache_folder() + return pair_records_cache_directory / f'remote_{self.handshake_info["peerDeviceInfo"]["identifier"]}.plist' + + def _pair(self) -> None: + pairing_consent_result = self._request_pair_consent() + self._init_srp_context(pairing_consent_result) + self._verify_proof() + self._save_pair_record_on_peer() + self._init_client_server_main_encryption_keys() + self._create_remote_unlock() + self.save_pair_record() + + def _request_pair_consent(self) -> PairConsentResult: + """ Display a Trust / Don't Trust dialog """ + + tlv = TLVBuf.build([ + {'type': PairingDataComponentType.METHOD, 'data': b'\x00'}, + {'type': PairingDataComponentType.STATE, 'data': b'\x01'}, + ]) + + self._send_pairing_data({'data': tlv, + 'kind': 'setupManualPairing', + 'sendingHost': 'User’s Mac mini', + 'startNewSession': True}) + assert 'awaitingUserConsent' in self._receive_plain_response()['event']['_0'] + response = self._receive_pairing_data() + data = self.decode_tlv(TLVBuf.parse( + response)) + return PairConsentResult(public_key=data[PairingDataComponentType.PUBLIC_KEY], + salt=data[PairingDataComponentType.SALT]) + + def _init_srp_context(self, pairing_consent_result: PairConsentResult) -> None: + # Receive server public and salt and process them. + client_session = SRPClientSession( + SRPContext('Pair-Setup', password='000000', prime=PRIME_3072, generator=PRIME_3072_GEN, + hash_func=hashlib.sha512)) + client_session.process(pairing_consent_result.public_key.hex(), + pairing_consent_result.salt.hex()) + self.srp_context = client_session + self.encryption_key = binascii.unhexlify(self.srp_context.key) + + def _verify_proof(self) -> None: + client_public = binascii.unhexlify(self.srp_context.public) + client_session_key_proof = binascii.unhexlify(self.srp_context.key_proof) + + tlv = TLVBuf.build([ + {'type': PairingDataComponentType.STATE, 'data': b'\x03'}, + {'type': PairingDataComponentType.PUBLIC_KEY, 'data': client_public[:255]}, + {'type': PairingDataComponentType.PUBLIC_KEY, 'data': client_public[255:]}, + {'type': PairingDataComponentType.PROOF, 'data': client_session_key_proof}, + ]) + + response = self._send_receive_pairing_data({ + 'data': tlv, + 'kind': 'setupManualPairing', + 'sendingHost': 'User’s Mac mini', + 'startNewSession': False}) + data = self.decode_tlv(TLVBuf.parse(response)) + assert self.srp_context.verify_proof(data[PairingDataComponentType.PROOF].hex().encode()) + + def _save_pair_record_on_peer(self) -> Mapping: + # HKDF with above computed key (SRP_compute_key) + Pair-Setup-Encrypt-Salt + Pair-Setup-Encrypt-Info + # result used as key for chacha20-poly1305 + setup_encryption_key = HKDF( + algorithm=hashes.SHA512(), + length=32, + salt=b'Pair-Setup-Encrypt-Salt', + info=b'Pair-Setup-Encrypt-Info', + ).derive(self.encryption_key) + + self.ed25519_private_key = Ed25519PrivateKey.generate() + + # HKDF with above computed key: + # (SRP_compute_key) + Pair-Setup-Controller-Sign-Salt + Pair-Setup-Controller-Sign-Info + signbuf = HKDF( + algorithm=hashes.SHA512(), + length=32, + salt=b'Pair-Setup-Controller-Sign-Salt', + info=b'Pair-Setup-Controller-Sign-Info', + ).derive(self.encryption_key) + + signbuf += self.identifier.encode() + signbuf += self.ed25519_private_key.public_key().public_bytes_raw() + + self.signature = self.ed25519_private_key.sign(signbuf) + + device_info = b'\xe7FaltIRK\x80\xe9\xe8-\xc0jIykVoT\x00\x19\xb1\xc7{FbtAddrQ14:98:77:76:76:EBCmacv\x14\x98wi' \ + b'\rq[remotepairing_serial_numberLC07HH0YHQ6NYIaccountIDa$3241983E-C373-4017-8963-0EB85C3CB8B7' \ + b'EmodelJMacmini9,1DnameQUser\xe2\x80\x99s Mac mini' + + tlv = TLVBuf.build([ + {'type': PairingDataComponentType.IDENTIFIER, 'data': self.identifier.encode()}, + {'type': PairingDataComponentType.PUBLIC_KEY, + 'data': self.ed25519_private_key.public_key().public_bytes_raw()}, + {'type': PairingDataComponentType.SIGNATURE, 'data': self.signature}, + {'type': PairingDataComponentType.INFO, 'data': device_info}, + ]) + + cip = ChaCha20Poly1305(setup_encryption_key) + encrypted_data = cip.encrypt(b'\x00\x00\x00\x00PS-Msg05', tlv, b'') + + tlv = TLVBuf.build([ + {'type': PairingDataComponentType.ENCRYPTED_DATA, 'data': encrypted_data[:255]}, + {'type': PairingDataComponentType.ENCRYPTED_DATA, 'data': encrypted_data[255:]}, + {'type': PairingDataComponentType.STATE, 'data': b'\x05'}, + ]) + + response = self._send_receive_pairing_data({ + 'data': tlv, + 'kind': 'setupManualPairing', + 'sendingHost': 'User’s Mac mini', + 'startNewSession': False}) + data = self.decode_tlv(TLVBuf.parse(response)) + + tlv = TLVBuf.parse(cip.decrypt( + b'\x00\x00\x00\x00PS-Msg06', data[PairingDataComponentType.ENCRYPTED_DATA], b'')) + + return tlv + + def _init_client_server_main_encryption_keys(self) -> None: + client_key = HKDF( + algorithm=hashes.SHA512(), + length=32, + salt=None, + info=b'ClientEncrypt-main', + ).derive(self.encryption_key) + self.client_cip = ChaCha20Poly1305(client_key) + + server_key = HKDF( + algorithm=hashes.SHA512(), + length=32, + salt=None, + info=b'ServerEncrypt-main', + ).derive(self.encryption_key) + self.server_cip = ChaCha20Poly1305(server_key) + + def _create_remote_unlock(self) -> None: + response = self._send_receive_encrypted_request({'request': {'_0': {'createRemoteUnlockKey': {}}}}) + self.remote_unlock_host_key = response['createRemoteUnlockKey']['hostKey'] + + def _create_listener(self, private_key: RSAPrivateKey, protocol: str = 'quic') -> Mapping: + request = {'request': {'_0': {'createListener': { + 'key': base64.b64encode( + private_key.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + ).decode(), + 'transportProtocolType': protocol}}}} + + response = self._send_receive_encrypted_request(request) + return response['createListener'] + + def _attempt_validate_pairing(self) -> None: + self.handshake_info = self._send_receive_handshake({'hostOptions': {'attemptPairVerify': True}, + 'wireProtocolVersion': XpcInt64Type(19)}) + + def _validate_pairing(self) -> bool: + pairing_data = TLVBuf.build([ + {'type': PairingDataComponentType.STATE, 'data': b'\x01'}, + {'type': PairingDataComponentType.PUBLIC_KEY, + 'data': self.x25519_private_key.public_key().public_bytes_raw()}, + ]) + response = self._send_receive_pairing_data({'data': pairing_data, + 'kind': 'verifyManualPairing', + 'startNewSession': True}) + + data = self.decode_tlv(TLVBuf.parse(response)) + peer_public_key = X25519PublicKey.from_public_bytes(data[PairingDataComponentType.PUBLIC_KEY]) + self.encryption_key = self.x25519_private_key.exchange(peer_public_key) + + derived_key = HKDF( + algorithm=hashes.SHA512(), + length=32, + salt=b'Pair-Verify-Encrypt-Salt', + info=b'Pair-Verify-Encrypt-Info', + ).derive(self.encryption_key) + cip = ChaCha20Poly1305(derived_key) + + # TODO: should be able to verify from this, instead we verify from a mock private key + # public_key = Ed25519PublicKey.from_public_bytes(self.pair_record['public_key']) + # encrypted_data = data[PairingDataComponentType.ENCRYPTED_DATA] + # data = self.decode_tlv(cip.decrypt(b'\x00\x00\x00\x00PV-Msg02', encrypted_data, b'')) + # peer_identifier = data[PairingDataComponentType.IDENTIFIER] + # peer_signature = data[PairingDataComponentType.SIGNATURE] + # signbuf = b'' + # signbuf += peer_public_key.public_bytes_raw() + # signbuf += peer_identifier + # signbuf += self.x25519_private_key.public_key().public_bytes_raw() + # print(public_key.verify(peer_signature, signbuf)) + + if self.pair_record is None: + private_key = Ed25519PrivateKey.from_private_bytes(b'\x00' * 0x20) + else: + private_key = Ed25519PrivateKey.from_private_bytes(self.pair_record['private_key']) + + signbuf = b'' + signbuf += self.x25519_private_key.public_key().public_bytes_raw() + signbuf += self.identifier.encode() + signbuf += peer_public_key.public_bytes_raw() + + signature = private_key.sign(signbuf) + + encrypted_data = cip.encrypt(b'\x00\x00\x00\x00PV-Msg03', TLVBuf.build([ + {'type': PairingDataComponentType.IDENTIFIER, 'data': self.identifier.encode()}, + {'type': PairingDataComponentType.SIGNATURE, 'data': signature}, + ]), b'') + + pairing_data = TLVBuf.build([ + {'type': PairingDataComponentType.STATE, 'data': b'\x03'}, + {'type': PairingDataComponentType.ENCRYPTED_DATA, 'data': encrypted_data}, + ]) + + response = self._send_receive_pairing_data({ + 'data': pairing_data, + 'kind': 'verifyManualPairing', + 'startNewSession': False}) + data = self.decode_tlv(TLVBuf.parse(response)) + + if PairingDataComponentType.ERROR in data: + self._send_pair_verify_failed() + return False + + return True + + def _send_pair_verify_failed(self) -> None: + self._send_plain_request({'event': {'_0': {'pairVerifyFailed': {}}}}) + + def _send_receive_encrypted_request(self, request: Mapping) -> Mapping: + nonce = Int64ul.build(self._encrypted_sequence_number) + b'\x00' * 4 + encrypted_data = self.client_cip.encrypt( + nonce, + json.dumps(request).encode(), + b'') + + response = self.service.send_receive_request({ + 'mangledTypeName': 'RemotePairing.ControlChannelMessageEnvelope', + 'value': {'message': { + 'streamEncrypted': {'_0': encrypted_data}}, + 'originatedBy': 'host', + 'sequenceNumber': XpcUInt64Type(self._sequence_number)}}) + self._encrypted_sequence_number += 1 + + encrypted_data = response['value']['message']['streamEncrypted']['_0'] + plaintext = self.server_cip.decrypt(nonce, encrypted_data, None) + return json.loads(plaintext)['response']['_1'] + + def _send_receive_handshake(self, handshake_data: Mapping) -> Mapping: + response = self._send_receive_plain_request({'request': {'_0': {'handshake': {'_0': handshake_data}}}}) + return response['response']['_1']['handshake']['_0'] + + def _send_receive_pairing_data(self, pairing_data: Mapping) -> Mapping: + self._send_pairing_data(pairing_data) + return self._receive_pairing_data() + + def _send_pairing_data(self, pairing_data: Mapping) -> None: + self._send_plain_request({'event': {'_0': {'pairingData': {'_0': pairing_data}}}}) + + def _receive_pairing_data(self) -> Mapping: + return self._receive_plain_response()['event']['_0']['pairingData']['_0']['data'] + + def _send_receive_plain_request(self, plain_request: Mapping): + self._send_plain_request(plain_request) + return self._receive_plain_response() + + def _send_plain_request(self, plain_request: Mapping) -> None: + self.service.send_request({ + 'mangledTypeName': 'RemotePairing.ControlChannelMessageEnvelope', + 'value': {'message': {'plain': {'_0': plain_request}}, + 'originatedBy': 'host', + 'sequenceNumber': XpcUInt64Type(self._sequence_number)}}) + self._sequence_number += 1 + + def _receive_plain_response(self) -> Mapping: + response = self.service.receive_response() + return response['value']['message']['plain']['_0'] + + @staticmethod + def decode_tlv(tlv_list: List[Container]) -> Mapping: + result = {} + for tlv in tlv_list: + if tlv.type in result: + result[tlv.type] += tlv.data + else: + result[tlv.type] = tlv.data + return result + + +def create_core_device_tunnel_service(rsd: RemoteServiceDiscoveryService, autopair: bool = True): + service = CoreDeviceTunnelService(rsd) + service.connect(autopair=autopair) + return service diff --git a/pymobiledevice3/remote/remote_pairing_service.py b/pymobiledevice3/remote/remote_pairing_service.py new file mode 100644 index 000000000..83c6eaf44 --- /dev/null +++ b/pymobiledevice3/remote/remote_pairing_service.py @@ -0,0 +1,16 @@ +from pymobiledevice3.remote.remote_service_discovery import RemoteServiceDiscoveryService + + +class RemotePairingService: + def __init__(self, rsd: RemoteServiceDiscoveryService): + self.rsd = rsd + self.service = None + + def connect(self) -> None: + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass diff --git a/pymobiledevice3/remote/remote_service_discovery.py b/pymobiledevice3/remote/remote_service_discovery.py new file mode 100644 index 000000000..b7c13c41b --- /dev/null +++ b/pymobiledevice3/remote/remote_service_discovery.py @@ -0,0 +1,29 @@ +from typing import Tuple + +from pymobiledevice3.remote.remotexpc import RemoteXPCConnection + +# from remoted ([RSDRemoteNCMDeviceDevice createPortListener]) +RSD_PORT = 58783 + + +class RemoteServiceDiscoveryService: + def __init__(self, address: Tuple[str, int]): + self.service = RemoteXPCConnection(address) + self.peer_info = None + + def connect(self) -> None: + self.service.connect() + self.peer_info = self.service.receive_response() + + def connect_to_service(self, name: str) -> 'RemoteXPCConnection': + service_port = int(self.peer_info['Services'][name]['Port']) + service = RemoteXPCConnection((self.service.address[0], service_port)) + service.connect() + return service + + def __enter__(self) -> 'RemoteServiceDiscoveryService': + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.service.close() diff --git a/pymobiledevice3/remote/remotexpc.py b/pymobiledevice3/remote/remotexpc.py new file mode 100644 index 000000000..586f3491f --- /dev/null +++ b/pymobiledevice3/remote/remotexpc.py @@ -0,0 +1,331 @@ +import socket +import uuid +from socket import create_connection +from typing import Mapping, Optional, Tuple + +from construct import Aligned, Array, Bytes, Const, CString, Default, Double, Enum, FixedSized, FlagsEnum, \ + GreedyBytes, Hex, If, Int32ul, Int64sl, Int64ul, LazyBound, Prefixed, Probe, Struct, Switch, \ + setGlobalPrintFullStrings, this +from scapy.contrib.http2 import H2DataFrame, H2Frame, H2ResetFrame, H2Setting, H2SettingsFrame, H2WindowUpdateFrame + +from pymobiledevice3.exceptions import StreamClosedError + +XpcMessageType = Enum(Hex(Int32ul), + NULL=0x00001000, + BOOL=0x00002000, + INT64=0x00003000, + UINT64=0x00004000, + DOUBLE=0x00005000, + POINTER=0x00006000, + DATE=0x00007000, + DATA=0x00008000, + STRING=0x00009000, + UUID=0x0000a000, + FD=0x0000b000, + SHMEM=0x0000c000, + MACH_SEND=0x0000d000, + ARRAY=0x0000e000, + DICTIONARY=0x0000f000, + ERROR=0x00010000, + CONNECTION=0x00011000, + ENDPOINT=0x00012000, + SERIALIZER=0x00013000, + PIPE=0x00014000, + MACH_RECV=0x00015000, + BUNDLE=0x00016000, + SERVICE=0x00017000, + SERVICE_INSTANCE=0x00018000, + ACTIVITY=0x00019000, + FILE_TRANSFER=0x0001a000, + ) + +setGlobalPrintFullStrings(True) +XpcFlags = FlagsEnum(Hex(Int32ul), + ALWAYS_SET=0x00000001, + DATA_PRESENT=0x00000100, + HEARTBEAT_REQUEST=0x00010000, + HEARTBEAT_RESPONSE=0x00020000, + FILE_TX_STREAM_REQUEST=0x00100000, + FILE_TX_STREAM_RESPONSE=0x00200000, + INIT_HANDSHAKE=0x00400000, + ) + +AlignedString = Aligned(4, CString('utf8')) +XpcNull = None +XpcBool = Int32ul +XpcInt64 = Int64sl +XpcUInt64 = Int64ul +XpcDouble = Double +XpcPointer = None +XpcDate = Int64ul +XpcData = Aligned(4, Prefixed(Int32ul, GreedyBytes)) +XpcString = Aligned(4, Prefixed(Int32ul, CString('utf8'))) +XpcUuid = Bytes(16) +XpcFd = Int32ul +XpcShmem = Struct('length' / Int32ul, Int32ul) +XpcArray = Prefixed(Int32ul, LazyBound(lambda: XpcObject)) + +XpcDictionaryEntry = Struct( + 'key' / AlignedString, + 'value' / LazyBound(lambda: XpcObject), +) + +XpcDictionary = Prefixed(Int32ul, Struct( + 'count' / Hex(Int32ul), + 'entries' / If(this.count > 0, Array(this.count, XpcDictionaryEntry)), +)) + +XpcObject = Struct( + 'type' / XpcMessageType, + 'data' / Switch(this.type, { + XpcMessageType.DICTIONARY: XpcDictionary, + XpcMessageType.STRING: XpcString, + XpcMessageType.INT64: XpcInt64, + XpcMessageType.UINT64: XpcUInt64, + XpcMessageType.DOUBLE: XpcDouble, + XpcMessageType.BOOL: XpcBool, + XpcMessageType.NULL: XpcNull, + XpcMessageType.UUID: XpcUuid, + XpcMessageType.POINTER: XpcPointer, + XpcMessageType.DATE: XpcDate, + XpcMessageType.DATA: XpcData, + XpcMessageType.FD: XpcFd, + XpcMessageType.SHMEM: XpcShmem, + XpcMessageType.ARRAY: XpcArray, + }, default=Probe(lookahead=20)), +) + +XpcPayload = Struct( + 'magic' / Hex(Const(0x42133742, Int32ul)), + 'protocol_version' / Hex(Const(0x00000005, Int32ul)), + 'message' / XpcObject, +) + +XpcWrapper = Struct( + 'magic' / Hex(Const(0x29b00b92, Int32ul)), + 'flags' / Default(XpcFlags, XpcFlags.ALWAYS_SET), + 'size' / Hex(Int64ul), + 'message_id' / Hex(Default(Int64ul, 0)), + 'payload' / If(this.size > 0, FixedSized(this.size, XpcPayload)), +) + + +def _get_dict_from_xpc_object(xpc_object): + type_ = xpc_object.type + + if type_ == XpcMessageType.DICTIONARY: + if xpc_object.data.count == 0: + return {} + result = {} + for entry in xpc_object.data.entries: + result[entry.key] = _get_dict_from_xpc_object(entry.value) + return result + + elif type_ == XpcMessageType.ARRAY: + result = [] + for entry in xpc_object.data.entries: + result.append(_get_dict_from_xpc_object(entry.value)) + return result + + elif type_ == XpcMessageType.BOOL: + return bool(xpc_object.data) + + elif type_ == XpcMessageType.INT64: + return XpcInt64Type(xpc_object.data) + + elif type_ == XpcMessageType.UINT64: + return XpcUInt64Type(xpc_object.data) + + elif type_ == XpcMessageType.UUID: + return uuid.UUID(bytes=xpc_object.data) + + elif type_ in (XpcMessageType.STRING, XpcMessageType.DATA): + return xpc_object.data + + else: + raise Exception(f'deserialize error: {xpc_object}') + + +class XpcInt64Type(int): + pass + + +class XpcUInt64Type(int): + pass + + +def get_object_from_xpc_wrapper(payload: bytes): + payload = XpcWrapper.parse(payload).payload + if payload is None: + return None + return _get_dict_from_xpc_object(payload.message) + + +def build_xpc_object(payload) -> Mapping: + if isinstance(payload, list): + entries = [] + for entry in payload: + entry = build_xpc_object(entry) + entries.append(entry) + xpc_object = { + 'type': XpcMessageType.ARRAY, + 'data': entries + } + elif isinstance(payload, dict): + entries = [] + for key, value in payload.items(): + entry = {'key': key, 'value': build_xpc_object(value)} + entries.append(entry) + xpc_object = { + 'type': XpcMessageType.DICTIONARY, + 'data': { + 'count': len(entries), + 'entries': entries, + } + } + elif isinstance(payload, bool): + xpc_object = { + 'type': XpcMessageType.BOOL, + 'data': payload, + } + elif isinstance(payload, str): + xpc_object = { + 'type': XpcMessageType.STRING, + 'data': payload, + } + elif isinstance(payload, bytes) or isinstance(payload, bytearray): + xpc_object = { + 'type': XpcMessageType.DATA, + 'data': payload, + } + elif type(payload).__name__ == 'XpcUInt64Type': + xpc_object = { + 'type': XpcMessageType.UINT64, + 'data': payload, + } + elif type(payload).__name__ == 'XpcInt64Type': + xpc_object = { + 'type': XpcMessageType.INT64, + 'data': payload, + } + else: + raise Exception(f'unrecognized type for: {payload} {type(payload)}') + + return xpc_object + + +def create_xpc_wrapper(d: Mapping, message_id: int = 0) -> bytes: + flags = XpcFlags.ALWAYS_SET + if len(d.keys()) > 0: + flags |= XpcFlags.DATA_PRESENT + + xpc_payload = { + 'message': build_xpc_object(d) + } + + xpc_wrapper = { + 'flags': flags, + 'size': len(XpcPayload.build(xpc_payload)), + 'message_id': message_id, + 'payload': xpc_payload, + } + return XpcWrapper.build(xpc_wrapper) + + +H2FRAME_SIZE = len(H2Frame()) +HTTP2_MAGIC = b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n' + + +class RemoteXPCConnection: + def __init__(self, address: Tuple[str, int]): + self.address = address + self.sock: Optional[socket.socket] = None + self.next_message_id = 0 + self.peer_info = None + + def __enter__(self) -> 'RemoteXPCConnection': + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() + + def connect(self) -> None: + self.sock = create_connection(self.address) + self._do_handshake() + + def close(self) -> None: + self.sock.close() + + def send_request(self, data: Mapping) -> None: + packet = H2Frame(type=0, stream_id=1) / H2DataFrame( + data=create_xpc_wrapper(data, message_id=self.next_message_id)) + self.sock.sendall(bytes(packet)) + # self.next_message_id += 1 + + def receive_response(self): + while True: + frame = self._receive_h2_frame() + if isinstance(frame.lastlayer(), H2ResetFrame): + raise StreamClosedError() + if not isinstance(frame.lastlayer(), H2DataFrame): + continue + xpc_wrapper = XpcWrapper.parse(frame.data) + if xpc_wrapper.payload is None: + continue + if xpc_wrapper.payload.message.data.entries is None: + continue + + self.next_message_id = xpc_wrapper.message_id + 1 + return get_object_from_xpc_wrapper(frame.data) + + def send_receive_request(self, data: Mapping): + self.send_request(data) + return self.receive_response() + + def _do_handshake(self) -> None: + self.sock.sendall(HTTP2_MAGIC) + + settings_frame = H2Frame() / H2SettingsFrame() + settings_frame.settings = [ + H2Setting(id=H2Setting.SETTINGS_MAX_CONCURRENT_STREAMS, value=100), + H2Setting(id=H2Setting.SETTINGS_INITIAL_WINDOW_SIZE, value=1048576), + ] + self.sock.sendall(bytes(settings_frame)) + + window_update_frame = H2Frame() / H2WindowUpdateFrame() + window_update_frame.win_size_incr = 983041 + self.sock.sendall(bytes(window_update_frame)) + + # send empty headers packet (stream_id=1) + self.sock.sendall(b'\x00\x00\x00\x01\x04\x00\x00\x00\x01') + + self.send_request({}) + + packet = H2Frame(type=0, stream_id=1) / H2DataFrame( + data=XpcWrapper.build({'size': 0, 'flags': 0x0201, 'payload': None})) + self.sock.sendall(bytes(packet)) + self.next_message_id += 1 + + # send empty headers packet (stream_id=3) + self.sock.sendall(b'\x00\x00\x00\x01\x04\x00\x00\x00\x03') + + packet = H2Frame(type=0, stream_id=3) / H2DataFrame( + data=XpcWrapper.build({'size': 0, 'flags': 0x00400001, 'payload': None})) + self.sock.sendall(bytes(packet)) + self.next_message_id += 1 + + assert isinstance(self._receive_h2_frame().lastlayer(), H2SettingsFrame) + + packet = H2Frame(flags={'A'}) / H2SettingsFrame() + self.sock.sendall(bytes(packet)) + + def _receive_h2_frame(self) -> H2Frame: + buf = self.sock.recv(H2FRAME_SIZE) + while True: + try: + H2Frame(buf).len + break + except AssertionError: # when not enough data + buf += self.sock.recv(1) + return H2Frame(buf) diff --git a/pymobiledevice3/remote/research.md b/pymobiledevice3/remote/research.md new file mode 100644 index 000000000..51fd23230 --- /dev/null +++ b/pymobiledevice3/remote/research.md @@ -0,0 +1,84 @@ +## lockdown-cu + +``` +-------- state 1 (send method) -------- + +HOST->DEVICE + +method 00 01 00 +state 06 01 01 + +-------- state 2 (receive salt and pubkey) -------- + +DEVICE->HOST + +salt 02 ?? +pubkey 03 20 ... +state 06 01 02 + +[optional] error 07 01 val + [optional] retry_delay 08 01 val + +const char PAIR_SETUP[] = "Pair-Setup"; +SRP_set_user_raw(srp, (const unsigned char*)PAIR_SETUP, sizeof(PAIR_SETUP)-1) +SRP_set_params(srp, kSRPModulus3072, sizeof(kSRPModulus3072), &kSRPGenerator5, 1, salt, salt_size) + username "Pair-Setup" + kSRPParameters_3072_SHA512 + kSRPGenerator5 + salt + +-------- state 3 (generate own pubkey, compute key from remote pubkey, compute response and send own pubkey and response) -------- + +HOST->DEVICE + +pubkey 03 20 (own pubkey) + SRP_compute_key(srp, &thekey, pubkey, pubkey_size) +response 04 ?? (compute response from srp) + SRP_respond(srp, &response); +state 06 01 03 + +-------- state 4 (receive proof and verify) -------- + +DEVICE->HOST + +proof 04 ?? +state 06 01 04 + +SRP_verify(srp, proof, proof_len) + +-------- state 5 (send encrypted info) -------- + +HOST->DEVICE + +// HKDF with above computed key (SRP_compute_key) + Pair-Setup-Encrypt-Salt + Pair-Setup-Encrypt-Info +// result used as key for chacha20-poly1305 + +unsigned char ed25519_pubkey[32]; +unsigned char ed25519_privkey[64]; +unsigned char ed25519seed[32]; + +ed25519_create_seed(ed25519seed); +ed25519_create_keypair(ed25519_pubkey, ed25519_privkey, ed25519seed); + +uuid 01 ?? (pairing uuid) +pubkey 03 20 ed25519_pubkey +ed_sig 0a 64 ed_sig +acl 12 ?? (entitlements?) + opack_encode_from_plist +encrypted_data 05 ?? + chacha20_poly1305_encrypt_64(setup_encryption_key, (unsigned char*)"PS-Msg05", NULL, 0, tlvbuf->data, tlvbuf->length, encrypted_buf, &encrypted_len); + accountID - pairing_uuid + ... +state 06 01 05 + +-------- state 6 (success? receive encrypted_data from device) -------- + +DEVICE->HOST + +encrypted_data 05 ?? + chacha20_poly1305_decrypt_64(setup_encryption_key, (unsigned char*)"PS-Msg06", NULL, 0, encrypted_buf, enc_len, plain_buf, &plain_len); +dev_info 11 ?? + +``` + + diff --git a/pyproject.toml b/pyproject.toml index 9d437a51f..016ff4a4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,8 @@ test = ["pytest", "cmd2_ext_test"] pymobiledevice3 = "pymobiledevice3.__main__:cli" [tool.setuptools] -package-data = { "pymobiledevice3" = ["resources/webinspector/*.js", "resources/dsc_uuid_map.json", "resources/notifications.txt"] } +package-data = { "pymobiledevice3" = ["resources/webinspector/*.js", "resources/dsc_uuid_map.json", + "resources/notifications.txt"] } [tool.setuptools.packages.find] exclude = ["docs*", "tests*"] diff --git a/requirements.txt b/requirements.txt index 9f6c5b207..1376edbb6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,3 +30,5 @@ pyimg4>=0.7 ipsw_parser>=1.1.2 remotezip zeroconf +aioquic +srptools \ No newline at end of file