pypush-plus-plus/icloud/gsa.py

434 lines
14 KiB
Python
Raw Normal View History

# Licensed under the MIT license. From https://github.com/JJTech0130/grandslam
import getpass
import hashlib
import hmac
import json
import locale
2023-10-22 16:27:11 -05:00
import logging
import plistlib as plist
import uuid
from base64 import b64decode, b64encode
from datetime import datetime
from random import randbytes
import pbkdf2
import requests
import srp._pysrp as srp
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
logger = logging.getLogger("gsa")
2023-10-19 07:33:08 -05:00
# Server to use for anisette generation
2023-10-22 16:27:11 -05:00
ANISETTE = False # Use local generation with AOSKit (macOS only)
# ANISETTE = "https://sign.rheaa.xyz/"
# ANISETTE = 'http://45.132.246.138:6969/'
2023-10-22 16:27:11 -05:00
# ANISETTE = "https://ani.sidestore.io/"
# ANISETTE = 'https://sideloadly.io/anisette/irGb3Quww8zrhgqnzmrx'
# ANISETTE = "http://jkcoxson.com:2052/"
2023-10-22 16:27:11 -05:00
# USER_AGENT = "com.apple.iCloudHelper/282 CFNetwork/1408.0.4 Darwin/22.5.0"
2023-10-21 14:52:17 -05:00
USER_AGENT = "akd/1.0 CFNetwork/978.0.7 Darwin/18.7.0"
2023-10-19 07:33:08 -05:00
# Created here so that it is consistent
USER_ID = uuid.uuid4()
DEVICE_ID = uuid.uuid4()
# Configure SRP library for compatibility with Apple's implementation
srp.rfc5054_enable()
srp.no_username_in_x()
# Disable SSL Warning
import urllib3
urllib3.disable_warnings()
2023-10-22 16:27:11 -05:00
def build_client(
emulated_device: str = "MacBookPro18,3", emulated_app: str = "accountsd"
) -> str:
"""'Client Information'
String in the following format:
<%MODEL%> <%OS%;%MAJOR%.%MINOR%(%SPMAJOR%,%SPMINOR%);%BUILD%> <%AUTHKIT_BUNDLE_ID%/%AUTHKIT_VERSION% (%APP_BUNDLE_ID%/%APP_VERSION%)>
Where:
MODEL: The model of the device (e.g. MacBookPro15,1 or 'PC'
OS: The OS of the device (e.g. Mac OS X or Windows)
MAJOR: The major version of the OS (e.g. 10)
MINOR: The minor version of the OS (e.g. 15)
SPMAJOR: The major version of the service pack (e.g. 0) (Windows only)
SPMINOR: The minor version of the service pack (e.g. 0) (Windows only)
BUILD: The build number of the OS (e.g. 19C57)
AUTHKIT_BUNDLE_ID: The bundle ID of the AuthKit framework (e.g. com.apple.AuthKit)
AUTHKIT_VERSION: The version of the AuthKit framework (e.g. 1)
APP_BUNDLE_ID: The bundle ID of the app (e.g. com.apple.dt.Xcode)
APP_VERSION: The version of the app (e.g. 3594.4.19)
"""
model = emulated_device
if emulated_device == "PC":
# We're emulating a PC, so we run Windows (Vista?)
os = "Windows"
os_version = "6.2(0,0);9200"
else:
# We're emulating a Mac, so we run macOS Ventura
os = "Mac OS X"
os_version = "13.4.1;22F8"
if emulated_app == "Xcode":
app_bundle = "com.apple.dt.Xcode"
app_version = "3594.4.19"
elif emulated_app == "accountsd":
app_bundle = "com.apple.accountsd"
app_version = "113"
else:
app_bundle = "com.apple.iCloud"
app_version = "7.21"
if os == "Windows":
authkit_bundle = "com.apple.AuthKitWin"
authkit_version = "1"
else:
authkit_bundle = "com.apple.AOSKit"
authkit_version = "282"
return f"<{model}> <{os};{os_version}> <{authkit_bundle}/{authkit_version} ({app_bundle}/{app_version})>"
2023-10-19 07:33:08 -05:00
def _generate_cpd() -> dict:
cpd = {
# Many of these values are not strictly necessary, but may be tracked by Apple
# I've chosen to match the AltServer implementation
# Not sure what these are for, needs some investigation
"bootstrap": True, # All implementations set this to true
"icscrec": True, # Only AltServer sets this to true
"pbe": False, # All implementations explicitly set this to false
"prkgen": True, # I've also seen ckgen
"svct": "iCloud", # In certian circumstances, this can be 'iTunes' or 'iCloud'
# Not included, but I've also seen:
# 'capp': 'AppStore',
# 'dc': '#d4c5b3',
# 'dec': '#e1e4e3',
# 'prtn': 'ME349',
}
2023-10-19 07:33:08 -05:00
cpd.update(generate_anisette_headers())
return cpd
2023-10-19 07:33:08 -05:00
2023-10-22 16:27:11 -05:00
def _generate_meta_headers(
serial: str = "0", user_id: uuid = uuid.uuid4(), device_id: uuid = uuid.uuid4()
) -> dict:
return {
"X-Apple-I-Client-Time": datetime.utcnow().replace(microsecond=0).isoformat()
+ "Z", # Current timestamp in ISO 8601 format
"X-Apple-I-TimeZone": str(
datetime.utcnow().astimezone().tzinfo
), # Abbreviation of the timezone of the device (e.g. EST)
2023-10-19 07:33:08 -05:00
# Locale of the device (e.g. en_US)
"loc": locale.getdefaultlocale()[0] or "en_US",
"X-Apple-Locale": locale.getdefaultlocale()[0] or "en_US",
2023-10-22 16:27:11 -05:00
"X-Apple-I-MD-RINFO": "17106176", # either 17106176 or 50660608
"X-Apple-I-MD-LU": b64encode(
str(user_id).upper().encode()
).decode(), # 'Local User ID': Base64 encoding of an uppercase UUID
"X-Mme-Device-Id": str(
device_id
).upper(), # 'Device Unique Identifier', uppercase UUID
"X-Apple-I-SRL-NO": serial, # Serial number
2023-10-19 07:33:08 -05:00
}
2023-10-22 16:27:11 -05:00
2023-10-19 07:33:08 -05:00
def _generate_local_anisette() -> dict:
logger.debug("Using local anisette generation")
2023-10-19 07:33:08 -05:00
"""Generates anisette data using AOSKit locally"""
import objc
from Foundation import NSBundle, NSClassFromString # type: ignore
AOSKitBundle = NSBundle.bundleWithPath_(
"/System/Library/PrivateFrameworks/AOSKit.framework"
)
objc.loadBundleFunctions(AOSKitBundle, globals(), [("retrieveOTPHeadersForDSID", b"")]) # type: ignore
util = NSClassFromString("AOSUtilities")
h = util.retrieveOTPHeadersForDSID_("-2")
2023-10-19 07:33:08 -05:00
return {
"X-Apple-I-MD": str(h["X-Apple-MD"]),
"X-Apple-I-MD-M": str(h["X-Apple-MD-M"]),
}
2023-10-22 16:27:11 -05:00
2023-10-19 07:33:08 -05:00
def _generate_remote_anisette(url: str) -> dict:
logger.debug("Using remote anisette generation: " + url)
2023-10-19 07:33:08 -05:00
h = json.loads(requests.get(url, timeout=5).text)
return {
"X-Apple-I-MD": h["X-Apple-I-MD"],
"X-Apple-I-MD-M": h["X-Apple-I-MD-M"],
}
2023-10-22 16:27:11 -05:00
2023-10-19 07:33:08 -05:00
def generate_anisette_headers() -> dict:
if isinstance(ANISETTE, str) and ANISETTE.startswith("http"):
a = _generate_remote_anisette(ANISETTE)
else:
2023-10-22 16:27:11 -05:00
a = _generate_local_anisette()
2023-10-19 07:33:08 -05:00
a.update(_generate_meta_headers(user_id=USER_ID, device_id=DEVICE_ID))
return a
2023-10-22 16:27:11 -05:00
2023-10-19 07:33:08 -05:00
def authenticated_request(parameters) -> dict:
body = {
"Header": {
"Version": "1.0.1",
},
"Request": {
2023-10-19 07:33:08 -05:00
"cpd": _generate_cpd(),
},
}
body["Request"].update(parameters)
# print(plist.dumps(body).decode('utf-8'))
headers = {
"Content-Type": "text/x-xml-plist",
"Accept": "*/*",
2023-10-21 14:52:17 -05:00
"User-Agent": USER_AGENT,
2023-10-19 07:33:08 -05:00
"X-MMe-Client-Info": build_client(emulated_app="Xcode"),
}
resp = requests.post(
"https://gsa.apple.com/grandslam/GsService2",
headers=headers,
data=plist.dumps(body),
verify=False, # TODO: Verify Apple's self-signed cert
timeout=5,
)
return plist.loads(resp.content)["Response"]
def check_error(r):
# Check for an error code
if "Status" in r:
status = r["Status"]
else:
status = r
if status["ec"] != 0:
raise Exception(f"Error {status['ec']}: {status['em']}")
2023-10-22 16:27:11 -05:00
# print(f"Error {status['ec']}: {status['em']}")
# return True
return False
def encrypt_password(password: str, salt: bytes, iterations: int) -> bytes:
p = hashlib.sha256(password.encode("utf-8")).digest()
return pbkdf2.PBKDF2(p, salt, iterations, hashlib.sha256).read(32)
def create_session_key(usr: srp.User, name: str) -> bytes:
k = usr.get_session_key()
if k is None:
raise Exception("No session key")
return hmac.new(k, name.encode(), hashlib.sha256).digest()
def decrypt_cbc(usr: srp.User, data: bytes) -> bytes:
extra_data_key = create_session_key(usr, "extra data key:")
extra_data_iv = create_session_key(usr, "extra data iv:")
# Get only the first 16 bytes of the iv
extra_data_iv = extra_data_iv[:16]
# Decrypt with AES CBC
cipher = Cipher(algorithms.AES(extra_data_key), modes.CBC(extra_data_iv))
decryptor = cipher.decryptor()
data = decryptor.update(data) + decryptor.finalize()
# Remove PKCS#7 padding
padder = padding.PKCS7(128).unpadder()
return padder.update(data) + padder.finalize()
2023-10-19 07:33:08 -05:00
def trusted_second_factor(dsid, idms_token):
identity_token = b64encode((dsid + ":" + idms_token).encode()).decode()
headers = {
"Content-Type": "text/x-xml-plist",
"User-Agent": "Xcode",
"Accept": "text/x-xml-plist",
"Accept-Language": "en-us",
"X-Apple-Identity-Token": identity_token,
2023-10-19 07:33:08 -05:00
"X-Apple-App-Info": "com.apple.gs.xcode.auth",
"X-Xcode-Version": "11.2 (11B41)",
2023-10-22 16:27:11 -05:00
"X-Mme-Client-Info": build_client(emulated_app="Xcode"),
}
2023-10-19 07:33:08 -05:00
headers.update(generate_anisette_headers())
2023-10-22 16:27:11 -05:00
# This will trigger the 2FA prompt on trusted devices
# We don't care about the response, it's just some HTML with a form for entering the code
# Easier to just use a text prompt
requests.get(
"https://gsa.apple.com/auth/verify/trusteddevice",
headers=headers,
verify=False,
timeout=10,
)
# Prompt for the 2FA code. It's just a string like '123456', no dashes or spaces
code = getpass.getpass("Enter 2FA code: ")
# code = input("Enter 2FA code: ")
headers["security-code"] = code
# Send the 2FA code to Apple
resp = requests.get(
"https://gsa.apple.com/grandslam/GsService2/validate",
headers=headers,
verify=False,
timeout=10,
)
r = plist.loads(resp.content)
if check_error(r):
return
logger.info("2FA successful")
2023-10-19 07:33:08 -05:00
def sms_second_factor(dsid, idms_token):
# TODO: Figure out how to make SMS 2FA work correctly
raise NotImplementedError("SMS 2FA is not yet implemented")
identity_token = b64encode((dsid + ":" + idms_token).encode()).decode()
headers = {
"Content-Type": "text/x-xml-plist",
"User-Agent": "Xcode",
# "Accept": "text/x-xml-plist",
"Accept": "application/x-buddyml",
"Accept-Language": "en-us",
"X-Apple-Identity-Token": identity_token,
2023-10-19 07:33:08 -05:00
"X-Apple-App-Info": "com.apple.gs.xcode.auth",
"X-Xcode-Version": "11.2 (11B41)",
2023-10-22 16:27:11 -05:00
"X-Mme-Client-Info": build_client(emulated_app="Xcode"),
}
2023-10-19 07:33:08 -05:00
headers.update(generate_anisette_headers())
body = {"serverInfo": {"phoneNumber.id": "1"}}
# This will send the 2FA code to the user's phone over SMS
# We don't care about the response, it's just some HTML with a form for entering the code
# Easier to just use a text prompt
requests.post(
"https://gsa.apple.com/auth/verify/phone/put?mode=sms",
data=plist.dumps(body),
headers=headers,
verify=False,
timeout=5,
)
# Prompt for the 2FA code. It's just a string like '123456', no dashes or spaces
code = input("Enter 2FA code: ")
body = {
"securityCode.code": code,
"serverInfo": {"mode": "sms", "phoneNumber.id": "1"},
}
# headers["security-code"] = code
# Send the 2FA code to Apple
resp = requests.post(
"https://gsa.apple.com/auth/verify/phone/securitycode?referrer=/auth/verify/phone/put",
headers=headers,
data=plist.dumps(body),
verify=False,
timeout=5,
)
print(resp.content.decode())
# r = plist.loads(resp.content)
# if check_error(r):
# return
# print("2FA successful")
2023-10-19 07:33:08 -05:00
def authenticate(username, password):
# Password is None as we'll provide it later
usr = srp.User(username, bytes(), hash_alg=srp.SHA256, ng_type=srp.NG_2048)
_, A = usr.start_authentication()
r = authenticated_request(
{
"A2k": A,
"ps": ["s2k", "s2k_fo"],
# "ps": ["s2k"],
"u": username,
"o": "init",
2023-10-19 07:33:08 -05:00
}
)
# Check for an error code
if check_error(r):
return
if r["sp"] != "s2k":
2023-10-22 16:27:11 -05:00
logger.error(
f"This implementation only supports s2k. Server returned {r['sp']}"
)
return
# Change the password out from under the SRP library, as we couldn't calculate it without the salt.
usr.p = encrypt_password(password, r["s"], r["i"]) # type: ignore
M = usr.process_challenge(r["s"], r["B"])
# Make sure we processed the challenge correctly
if M is None:
logger.critical("Failed to process challenge")
return
r = authenticated_request(
{
"c": r["c"],
"M1": M,
"u": username,
"o": "complete",
2023-10-19 07:33:08 -05:00
}
)
if check_error(r):
return
# Make sure that the server's session key matches our session key (and thus that they are not an imposter)
usr.verify_session(r["M2"])
if not usr.authenticated():
logger.critical("Failed to verify session")
return
spd = decrypt_cbc(usr, r["spd"])
# For some reason plistlib doesn't accept it without the header...
PLISTHEADER = b"""\
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE plist PUBLIC '-//Apple//DTD PLIST 1.0//EN' 'http://www.apple.com/DTDs/PropertyList-1.0.dtd'>
"""
spd = plist.loads(PLISTHEADER + spd)
if "au" in r["Status"] and r["Status"]["au"] == "trustedDeviceSecondaryAuth":
logger.info("Trusted device authentication required")
# Replace bytes with strings
for k, v in spd.items():
if isinstance(v, bytes):
spd[k] = b64encode(v).decode()
2023-10-19 07:33:08 -05:00
trusted_second_factor(spd["adsid"], spd["GsIdmsToken"])
return authenticate(username, password)
elif "au" in r["Status"] and r["Status"]["au"] == "secondaryAuth":
logger.info("SMS authentication required")
2023-10-19 07:33:08 -05:00
sms_second_factor(spd["adsid"], spd["GsIdmsToken"])
elif "au" in r["Status"]:
logger.info(f"Unknown auth value {r['Status']['au']}")
return
else:
# print("Assuming 2FA is not required")
2023-10-22 16:27:11 -05:00
return spd