diff --git a/.gitignore b/.gitignore index b777fcf..6a93e4e 100644 --- a/.gitignore +++ b/.gitignore @@ -160,4 +160,6 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +.idea/ + +attachments/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..423611c --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# pypush +`pypush` is a POC demo of my recent iMessage reverse-engineering. +It can currently register as a new device on an Apple ID, set up encryption keys, and ***send and receive iMessages***! + +`pypush` is completely platform-independent, and does not require a Mac or other Apple device to use! + +## Installation +It's pretty self explanatory: +1. `git clone https://github.com/JJTech0130/pypush` +2. `pip3 install -r requirements.txt` +3. `python3 ./demo.py` + +## Troubleshooting +If you have any issues, please join [the Discord](https://discord.gg/BVvNukmfTC) and ask for help. + +## Operation +`pypush` will generate a `config.json` in the repository when you run demo.py. DO NOT SHARE THIS FILE. +It contains all the encryption keys necessary to log into you Apple ID and send iMessages as you. + +Once it loads, it should prompt you with `>>`. Type `help` and press enter for a list of supported commands. + +## Special Notes +### Unicorn dependency +`pypush` currently uses the Unicorn CPU emulator and a custom MachO loader to load a framework from an old version of macOS, +in order to call some obfuscated functions. + +This is only necessary during initial registration, so theoretically you can register on one device, and then copy the `config.json` +to another device that doesn't support the Unicorn emulator. Or you could switch out the emulator for another x86 emulator if you really wanted to. + +### Public key caching +iMessage will cache public keys. If you get decryption errors in pypush or can only send and not receive messages from another device, +try logging out and back into iMessage on that device, forcing it to refresh it's key cache. Alternatively, you can wait and the cache should +expire eventually. diff --git a/demo.py b/demo.py index d650ba6..b9793d0 100644 --- a/demo.py +++ b/demo.py @@ -127,11 +127,33 @@ threading.Thread(target=input_thread, daemon=True).start() print("Type 'help' for help") +def fixup_handle(handle): + if handle.startswith('tel:+'): + return handle + elif handle.startswith('mailto:'): + return handle + elif handle.startswith('tel:'): + return 'tel:+' + handle[4:] + elif handle.startswith('+'): + return 'tel:' + handle + # If the handle starts with a number + elif handle[0].isdigit(): + # If the handle is 10 digits, assume it's a US number + if len(handle) == 10: + return 'tel:+1' + handle + # If the handle is 11 digits, assume it's a US number with country code + elif len(handle) == 11: + return 'tel:+' + handle + else: # Assume it's an email + return 'mailto:' + handle + current_participants = [] +current_effect = None while True: msg = im.receive() if msg is not None: - print(f'[{msg.sender}] {msg.text}') + # print(f'[{msg.sender}] {msg.text}') + print(msg.to_string()) attachments = msg.attachments() if len(attachments) > 0: @@ -142,7 +164,7 @@ while True: with open(attachments_path + attachment.name, "wb") as attachment_file: attachment_file.write(attachment.versions[0].data()) - print(f"({len(attachments)} attachment{'s' if len(attachments) == 1 else ''} have been downloaded and put " + print(f"({len(attachments)} attachment{'s have' if len(attachments) != 1 else ' has'} been downloaded and put " f"in {attachments_path})") if len(INPUT_QUEUE) > 0: @@ -153,28 +175,60 @@ while True: print('quit (q): quit') #print('send (s) [recipient] [message]: send a message') print('filter (f) [recipient]: set the current chat') + print('effect (e): adds an iMessage effect to the next sent message') print('note: recipient must start with tel: or mailto: and include the country code') + print('handle <handle>: set the current handle (for sending messages)') print('\\: escape commands (will be removed from message)') elif msg == 'quit' or msg == 'q': break - elif msg.startswith('filter ') or msg.startswith('f '): + elif msg == 'effect' or msg == 'e' or msg.startswith("effect ") or msg.startswith("e "): + msg = msg.split(" ") + if len(msg) < 2 or msg[1] == "": + print("effect [effect namespace]") + else: + print(f"next message will be sent with [{msg[1]}]") + current_effect = msg[1] + elif msg == 'filter' or msg == 'f' or msg.startswith('filter ') or msg.startswith('f '): # Set the curernt chat msg = msg.split(' ') if len(msg) < 2 or msg[1] == '': print('filter [recipients]') else: - print(f'Filtering to {msg[1:]}') - current_participants = msg[1:] + print(f'Filtering to {[fixup_handle(h) for h in msg[1:]]}') + current_participants = [fixup_handle(h) for h in msg[1:]] + elif msg == 'handle' or msg.startswith('handle '): + msg = msg.split(' ') + if len(msg) < 2 or msg[1] == '': + print('handle [handle]') + print('Available handles:') + for h in user.handles: + if h == user.current_handle: + print(f'\t{h} (current)') + else: + print(f'\t{h}') + else: + h = msg[1] + h = fixup_handle(h) + if h in user.handles: + print(f'Using {h} as handle') + user.current_handle = h + else: + print(f'Handle {h} not found') + elif current_participants != []: if msg.startswith('\\'): msg = msg[1:] im.send(imessage.iMessage( text=msg, participants=current_participants, - sender=user.handles[0] + sender=user.current_handle, + effect=current_effect )) + current_effect = None else: print('No chat selected, use help for help') + + time.sleep(0.1) # elif msg.startswith('send') or msg.startswith('s'): # msg = msg.split(' ') diff --git a/ids/__init__.py b/ids/__init__.py index 5d7226b..7e561c4 100644 --- a/ids/__init__.py +++ b/ids/__init__.py @@ -45,6 +45,8 @@ class IDSUser: self._auth_keypair, self._push_keypair, ) + self.current_handle = self.handles[0] + # Uses an existing authentication keypair def restore_authentication( @@ -53,6 +55,7 @@ class IDSUser: self._auth_keypair = auth_keypair self.user_id = user_id self.handles = handles + self.current_handle = self.handles[0] # This is a separate call so that the user can make sure the first part succeeds before asking for validation data def register(self, validation_data: str): @@ -79,5 +82,5 @@ class IDSUser: self._id_keypair = id_keypair def lookup(self, uris: list[str], topic: str = "com.apple.madrid") -> any: - return query.lookup(self.push_connection, self.handles[0], self._id_keypair, uris, topic) + return query.lookup(self.push_connection, self.current_handle, self._id_keypair, uris, topic) diff --git a/imessage.py b/imessage.py index 1922693..6722f44 100644 --- a/imessage.py +++ b/imessage.py @@ -57,9 +57,6 @@ class MMCSFile(AttachmentFile): url=self.url, headers={ "User-Agent": f"IMTransferAgent/900 CFNetwork/596.2.3 Darwin/12.2.0 (x86_64) (Macmini5,1)", - "x-apple-mmcs-proto-version": f"basic {base64.encodebytes(f'benjiebabioles@outlook.com:!Qwerty4!'.encode('utf-8'))}", - "x-apple-mmcs-dataclass": f"basic {base64.encodebytes(f'benjiebabioles@outlook.com:!Qwerty4!'.encode('utf-8'))}", - # "Authorization": f"basic {base64.encodebytes(f'benjiebabioles@outlook.com:!Qwerty4!'.encode('utf-8'))}", # "MMCS-Url": self.url, # "MMCS-Signature": str(base64.encodebytes(self.signature)), # "MMCS-Owner": self.owner @@ -146,6 +143,8 @@ class iMessage: """Group ID of the message, will be randomly generated if not provided""" body: BalloonBody | None = None """BalloonBody, may be None""" + effect: str | None = None + """iMessage effect sent with this message, may be None""" _compressed: bool = True """Internal property representing whether the message should be compressed""" @@ -184,7 +183,7 @@ class iMessage: return True - def from_raw(message: bytes) -> "iMessage": + def from_raw(message: bytes, sender: str | None = None) -> "iMessage": """Create an `iMessage` from raw message bytes""" compressed = False try: @@ -199,12 +198,11 @@ class iMessage: text=message.get("t", ""), xml=message.get("x"), participants=message.get("p", []), - sender=message.get("p", [])[-1] if message.get("p", []) != [] else None, + sender=sender if sender is not None else message.get("p", [])[-1] if "p" in message else None, id=uuid.UUID(message.get("r")) if "r" in message else None, group_id=uuid.UUID(message.get("gid")) if "gid" in message else None, - body=BalloonBody(message["bid"], message["b"]) - if "bid" in message and "b" in message - else None, + body=BalloonBody(message["bid"], message["b"]) if "bid" in message and "b" in message else None, + effect=message["iid"] if "iid" in message else None, _compressed=compressed, _raw=message, ) @@ -223,6 +221,7 @@ class iMessage: "pv": 0, "gv": "8", "v": "1", + "iid": self.effect } # Remove keys that are None @@ -237,6 +236,12 @@ class iMessage: return d + def to_string(self) -> str: + message_str = f"[{self.sender}] '{self.text}'" + if self.effect is not None: + message_str += f" with effect [{self.effect}]" + return message_str + class iMessageUser: """Represents a logged in and connected iMessage user. @@ -432,14 +437,21 @@ class iMessageUser: decrypted = self._decrypt_payload(payload) - return iMessage.from_raw(decrypted) + return iMessage.from_raw(decrypted, body['sP']) + KEY_CACHE_HANDLE: str = "" KEY_CACHE: dict[bytes, tuple[bytes, bytes]] = {} """Mapping of push token : (public key, session token)""" USER_CACHE: dict[str, list[bytes]] = {} """Mapping of handle : [push tokens]""" def _cache_keys(self, participants: list[str]): + # Clear the cache if the handle has changed + if self.KEY_CACHE_HANDLE != self.user.current_handle: + self.KEY_CACHE_HANDLE = self.user.current_handle + self.KEY_CACHE = {} + self.USER_CACHE = {} + # Check to see if we have cached the keys for all of the participants if all([p in self.USER_CACHE for p in participants]): return