Apple Authentication Internals
First Post:
Last Update:
Word Count: 3.8k
Read Time: 18min
实验性文档 — 本文描述的协议来自社区逆向工程,Apple 从未公开任何相关 API 文档。
内容随 Apple 服务器端更新可能失效。
来源说明
本文中的代码均来自 dependencies/altsign.js/src/apple/, 主要参考 https://github.com/lbr77/SideImpactor 的实现,并结合其他来源进行注释和说明。
作者尝试补全了缺失的 SMS 2FA 流程,并对整个认证与证书签发流程进行了整理和总结,形成了本文档。
总览:两条流水线
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| ┌─────────────────────────────────────────────────────────────────┐ │ Flow A: Apple ID 认证 │ │ │ │ 客户端 Apple GSA │ │ │ (gsa.apple.com) │ │ │──── SRP init ──────▶│ 生成盐、B、challenge │ │ │◀─── salt,B,c ───────│ │ │ │──── SRP complete ──▶│ 验证 M1 (密码证明) │ │ │◀─── M2, spd ────────│ spd = AES-CBC(adsid, idmsToken, sk) │ │ │ │ │ │ │ [可选: 2FA] │ │ │ │──── device/SMS ────▶│ │ │ │◀─── 验证结果 ────────│ │ │ │ │ │ │ │──── apptokens ─────▶│ 请求 Xcode 作用域 token │ │ │◀─── et (加密) ───────│ et = AES-GCM(token, sk) │ │ │ └─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐ │ Flow B: 开发者证书签发 │ │ │ │ 客户端 Apple Developer Services │ │ │ (developerservices2.apple.com) │ │ │──── viewDeveloper ──▶│ 获取账户信息 │ │ │──── listTeams ──────▶│ 选择 Team │ │ │──── addCertificate ─▶│ 上传 CSR,申请开发证书 │ │ │──── addDevice ──────▶│ 注册目标设备 UDID │ │ │──── addAppId ───────▶│ 创建 / 复用 App ID │ │ │──── downloadProfile ▶│ 下载 Provisioning Profile │ │ │ │ │ └── zsign-wasm ──────── 在浏览器内完成 IPA 重签名 │ │ │ └─────────────────────────────────────────────────────────────────┘
|
Part 1:Apple ID 认证
1.1 Anisette — “设备身份证”
在发起任何认证请求之前,客户端必须附带一组 Anisette headers。Apple 用这组数据来判断请求是否来自真实的 Apple 设备。
| Header |
含义 |
X-Apple-I-MD |
一次性密码 (HOTP/TOTP 衍生) |
X-Apple-I-MD-M |
Machine ID,标识这台”设备” |
X-Apple-I-MD-LU |
Local User ID |
X-Apple-I-MD-RINFO |
Routing Info (整数) |
X-Mme-Device-Id |
设备唯一标识符 |
X-MMe-Client-Info |
设备描述字符串 |
X-Apple-I-Client-Time |
请求时间 (ISO-8601,无毫秒) |
这些数据由 @lbr77/anisette-js 生成,底层调用 Apple 的 MobileDevice.framework 中的 ADI 库来计算 OTP。没有合法的 Anisette 数据,Apple 服务器会直接拒绝请求。
1 2 3 4 5 6 7 8 9 10
| const clientDictionary = { 'X-Apple-I-Client-Time': this.formatDate(anisetteData.date), 'X-Apple-I-MD': anisetteData.oneTimePassword, 'X-Apple-I-MD-M': anisetteData.machineID, 'X-Apple-I-MD-LU': anisetteData.localUserID, 'X-Apple-I-MD-RINFO': anisetteData.routingInfo, 'X-Mme-Device-Id': anisetteData.deviceUniqueIdentifier, };
|
1.2 SRP-6a — 零知识密码证明
Apple 使用 SRP (Secure Remote Password) 协议让客户端向服务器证明”我知道密码”,但全程不传输密码明文,服务器也无法从截获的流量推算出密码。
标准 SRP vs Apple 的改动
| 项目 |
标准 SRP (RFC 5054) |
Apple 的实现 |
| 群组参数 |
多种可选 |
2048-bit 固定 |
x = H(salt || H(I || ":" || P)) |
标准 |
**noUsernameInX = true**,即 x = H(salt || H(P)) |
| 密码哈希 |
直接 SHA |
PBKDF2,且先对密码做 SHA-256 |
| 密码派生 |
单一方案 |
服务器动态选择 s2k 或 s2k_fo |
完整握手时序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| 客户端 Apple GSA │ │ │ 1. 生成随机私钥 a (32 bytes) │ │ 计算 A = g^a mod N │ │ │ │──── POST /grandslam/GsService2 ───────────────▶│ │ { o:"init", u:appleID, A2k:A, │ │ ps:["s2k","s2k_fo"] } │ │ │ │◀─── { sp:"s2k", s:salt, i:iterations, │ │ B:serverPublicKey, c:challenge } ─────────│ │ │ │ 2. 派生密码 key │ │ passwordKey = PBKDF2(SHA256(password), │ │ salt, iterations, 32) │ │ │ │ 3. 计算 M1 (客户端证明) │ │ M1 = H(H(N) XOR H(g), H(I), salt, A, B, K)│ │ │ │──── POST /grandslam/GsService2 ───────────────▶│ │ { o:"complete", c:challenge, │ │ M1:clientProof } │ │ │ │◀─── { M2:serverProof, spd:encrypted_payload } ─│ │ │ │ 4. 验证 M2,解密 spd │ │ → 得到 adsid, GsIdmsToken, sk, c │
|
密码派生:s2k 与 s2k_fo
服务器通过响应中的 sp 字段告知客户端使用哪种方案。两种方案都是先 SHA-256 密码,区别在于 PBKDF2 的输入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| async function derivePasswordKey( password: string, salt: Uint8Array, iterations: number, isS2K: boolean ): Promise<Uint8Array> { const passwordData = new TextEncoder().encode(password); const passwordHash = await crypto.subtle.digest('SHA-256', passwordData); const hashBytes = new Uint8Array(passwordHash);
let digest: Uint8Array; if (isS2K) { digest = hashBytes; } else { const hexChars = '0123456789abcdef'; const hexBytes = new Uint8Array(hashBytes.length * 2); for (let i = 0; i < hashBytes.length; i++) { const byte = hashBytes[i]!; hexBytes[i * 2] = hexChars.charCodeAt((byte >> 4) & 0x0f); hexBytes[i * 2 + 1] = hexChars.charCodeAt(byte & 0x0f); } digest = hexBytes; }
return pbkdf2(digest, salt, iterations, 32); }
|
为什么有两种方案? s2k_fo 是较早的遗留方案。新账号默认使用 s2k。服务器根据账号创建时间决定。
1.3 解密 spd:获取身份令牌
SRP 完成后,服务器返回 spd(Secure Payload Data)——一段用 SRP 会话密钥加密的 plist。里面包含后续所有操作需要的身份信息。
密钥派生
1 2 3 4
| SRP 会话密钥 K (32 bytes) │ ├── HMAC-SHA256("extra data key:") → AES-CBC key (32 bytes) └── HMAC-SHA256("extra data iv:") → AES-CBC IV (32 bytes,取前 16)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| async function createSessionKey( sessionKey: Uint8Array, keyName: string ): Promise<Uint8Array> { const keyData = new TextEncoder().encode(keyName); const hmacKey = await crypto.subtle.importKey( 'raw', sessionKey.buffer, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const signature = await crypto.subtle.sign('HMAC', hmacKey, keyData.buffer); return new Uint8Array(signature); }
|
解密流程
1 2 3 4 5 6 7 8 9 10 11 12 13
| const extraDataKey = await createSessionKey(sessionKey, 'extra data key:'); const extraDataIV = await createSessionKey(sessionKey, 'extra data iv:');
const decryptedData = await decryptCBC(spd, extraDataKey, extraDataIV); const decryptedPlist = parsePlist(new TextDecoder().decode(decryptedData));
const adsid = decryptedPlist['adsid']; const idmsToken = decryptedPlist['GsIdmsToken']; const sk = atob(decryptedPlist['sk']); const c2 = atob(decryptedPlist['c']);
|
1.4 双因素认证
当 completeResponse['Status']['au'] 字段存在时,Apple 要求 2FA。有两种类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const authType = status?.['au'];
if (authType === 'trustedDeviceSecondaryAuth' || authType === 'phoneSecondaryAuth') { const handle2FA = authType === 'phoneSecondaryAuth' ? handlePhoneTwoFactor : handleDeviceTwoFactor;
const success = await handle2FA( this.fetch, adsid, idmsToken, anisetteData, verificationHandler, this.formatDate.bind(this) );
if (success) { return this.authenticate(appleID, password, anisetteData, verificationHandler); } }
|
类型 A:Trusted Device(受信设备推送)
适用于账号有绑定 iPhone/iPad/Mac 的情况。
1 2 3 4 5 6 7 8 9 10
| 客户端 Apple GSA │ │ │ GET /auth/verify/trusteddevice│ → Apple 向用户所有受信设备推送弹窗 │◀─── 200 (含 trustedPhoneNumbers)│ → 同时返回手机号列表(SMS 兜底) │ │ │ 用户在设备上查看 6 位数字 │ │ │ │ GET /grandslam/GsService2/validate │ Header: security-code: 123456 │ → 用 header 而非 body 传递验证码 │◀─── { ec: 0 } ────────────────│ ec=0 表示验证通过
|
1 2 3 4 5 6 7 8
| const submitDeviceCode = async (code: string) => { const verifyHeaders = { ...headers, 'security-code': code.trim() }; const resp = await fetch.get(GSA_VALIDATE_URL, verifyHeaders); const plist = parsePlist(await resp.text()); resolve(plist['ec'] === 0); };
|
类型 B:Phone(仅手机号账号)
账号没有绑定任何 Apple 设备,只有手机号。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 客户端 Apple GSA │ │ │ GET /auth/verify/phone ───────▶│ 获取可用手机号列表 │◀─── [{ id:1, obfuscatedNumber: "+86 *** **** 1234" }] │ │ │ PUT /auth/verify/phone │ → 触发发送 SMS 到 id=1 的手机 │ Body: { phoneNumber:{id:1}, mode:"sms" } │◀─── 200 ───────────────────────│ │ │ │ 用户收到短信验证码 │ │ │ │ POST /auth/verify/phone/securitycode │ Body: { phoneNumber:{id:1}, │ │ securityCode:{code:"123456"}, │ mode:"sms" } │ │◀─── { ec: 0 } ────────────────│
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const submitSmsCode = async (phoneId: number, code: string): Promise<void> => { const body = JSON.stringify({ phoneNumber: { id: phoneId }, securityCode: { code: code.trim() }, mode: 'sms', }); const resp = await fetch.request('POST', GSA_PHONE_CODE_URL, jsonHeaders, body); let ok = resp.ok; try { const parsed = JSON.parse(await resp.text()) as Record<string, unknown>; if (parsed['ec'] !== undefined) ok = parsed['ec'] === 0; } catch { } if (!ok) throw new Error('SMS verification failed'); resolve(true); };
|
为什么两种路径都提供相同的 TwoFactorContext?
1 2 3 4 5 6 7
| export interface TwoFactorContext { submitDeviceCode: (code: string) => void; trustedPhoneNumbers: TrustedPhoneNumber[]; requestSms: (phoneId: number) => Promise<void>; submitSmsCode: (phoneId: number, code: string) => Promise<void>; }
|
设计原则:UI 层不感知 au 类型。
trustedDeviceSecondaryAuth:有受信设备 → 推送通知。但 Apple 同时允许 SMS 兜底,所以 device 路径也提供完整 SMS callbacks。
phoneSecondaryAuth:无受信设备 → 只有 SMS。submitDeviceCode 保留是为了接口对称,不影响逻辑。
UI 只根据 trustedPhoneNumbers.length 决定是否渲染 SMS 按钮。
1.5 App Token Exchange — 获取 Xcode 凭证
2FA 通过(或不需要 2FA)后,使用 spd 中的 sk 和 c2 交换一个限定作用域的 App Token。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 请求参数: { o: "apptokens", u: adsid, app: ["com.apple.gs.xcode.auth"], // 请求 Xcode 作用域 c: c2, // challenge (来自 spd) t: idmsToken, checksum: HMAC-SHA256(sk, "apptokens" || adsid || "com.apple.gs.xcode.auth") }
响应: { et: <AES-GCM encrypted token> }
|
Apple 的 GCM token 有固定的 wire format:
1 2 3 4 5 6 7 8 9 10
| ┌────────────────────────────────────────────────────────┐ │ Offset │ Length │ 内容 │ ├────────┼────────┼───────────────────────────────────────┤ │ 0 │ 3 │ Magic: 0x58 0x59 0x5A ("XYZ") │ │ 3 │ 16 │ IV (nonce) │ │ 19 │ n │ Ciphertext │ │ 19+n │ 16 │ GCM Authentication Tag │ └────────────────────────────────────────────────────────┘
Magic 字节同时作为 GCM 的 Additional Authenticated Data (AAD)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| async function decryptGCM(data: Uint8Array, key: Uint8Array) { if (data[0] !== 0x58 || data[1] !== 0x59 || data[2] !== 0x5a) return null;
const magic = data.slice(0, 3); const iv = data.slice(3, 19); const ciphertext = data.slice(19, data.length - 16); const tag = data.slice(data.length - 16);
const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv.buffer, additionalData: magic.buffer, tagLength: 128, }, cryptoKey, new Uint8Array([...ciphertext, ...tag]).buffer ); }
|
Checksum:防重放
请求必须携带一个 HMAC 校验和,防止 token 请求被重放:
1 2 3 4 5 6 7 8 9 10 11 12
|
async function createAppTokensChecksum(sk, adsid, apps) { const parts = [ encode('apptokens'), encode(adsid), ...apps.map(encode), ]; const combined = concatenate(parts); return new Uint8Array(await hmacSign(sk, combined)); }
|
Part 2:开发者证书签发流程
认证完成后得到 AppleAPISession(dsid + authToken + anisetteData),用它访问 Apple Developer Services API。
2.1 请求格式
所有 Developer Services 请求均为 plist over HTTP POST,使用 Xcode 身份伪装:
1 2 3 4 5 6 7 8 9 10 11
| { 'Content-Type': 'text/x-xml-plist', 'User-Agent': 'Xcode', 'Accept': 'text/x-xml-plist', 'X-Apple-App-Info': 'com.apple.gs.xcode.auth', 'X-Xcode-Version': '11.2 (11B41)', 'X-Apple-I-Identity-Id': session.dsid, 'X-Apple-GS-Token': session.authToken, }
|
Base URL: https://developerservices2.apple.com/services/QH65B2/
Client ID: XABBG36SBA(硬编码,逆向自 Xcode)
每个请求 body 包含随机 requestId(UUID)防重放:
1 2 3 4 5 6 7
| { clientId: 'XABBG36SBA', protocolVersion: 'QH65B2', requestId: crypto.randomUUID().toUpperCase(), }
|
2.2 证书签发(CSR → CER)
流程
1 2 3 4 5 6 7 8 9 10 11 12 13
| 浏览器内 Apple Developer Services │ │ │ 1. 生成 RSA-2048 密钥对 │ │ (Web Crypto API) │ │ │ │ 2. 构造 PKCS │ Subject: CN=webmuxd-<timestamp> │ │ │ │──── addCertificate (CSR) ─────────────▶│ │◀─── { certificate: { data: DER } } ────│ │ │ │ 3. 私钥本地保存(Apple 不存私钥) │ │ base64 → localStorage │
|
证书限额与自动续期
Apple 每个 Team 最多允许 3 张开发证书。超额时需要先吊销旧证书:
1 2 3 4 5 6 7 8 9 10 11 12 13
| try { created = await api.addCertificate(session, team, `webmuxd-${Date.now()}`); } catch (error) { const message = String(error); if (!message.includes('7460') || certificates.length === 0) throw error;
const target = certificates[0]; await api.revokeCertificate(session, team, target); created = await api.addCertificate(session, team, `webmuxd-${Date.now()}`); }
|
本地缓存:避免重复消耗证书名额
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const cached = loadCachedSigningIdentity(appleId, team.identifier); if (cached) { const matched = certificates.find(c => c.identifier === cached.certId); if (matched) { return { certificate: { ...matched, publicKey: base64ToBytes(cached.certPublicKeyBase64) }, privateKey: base64ToBytes(cached.privateKeyBase64), }; } }
|
2.3 设备注册
将目标设备的 UDID 注册到 Team 的设备列表,Profile 才能覆盖该设备。
1 2 3 4 5 6 7 8 9
|
const devices = await api.fetchDevices(session, team); const existed = devices.find(d => normalizeUdid(d.identifier) === normalizedUdid); if (existed) return;
await api.registerDevice(session, team, deviceName, normalizedUdid);
|
2.4 App ID 与 Bundle ID 作用域
Apple 要求 Bundle ID 以 Team ID 结尾(免费账号限制):
1 2 3 4 5 6 7 8 9
|
function buildTeamScopedBundleId(baseBundleId: string, teamId: string): string { const lowerBase = baseBundleId.toLowerCase(); const lowerTeam = teamId.toLowerCase(); if (lowerBase.endsWith(`.${lowerTeam}`)) return baseBundleId; return `${baseBundleId}.${teamId}`; }
|
2.5 Provisioning Profile
Profile 将「证书 + 设备列表 + App ID + 权限」打包签名,是 Apple 授权安装的凭据:
1 2 3 4 5 6 7
| Profile 包含: - App ID(Bundle ID) - Team 信息 - 允许的证书公钥列表 - 允许的设备 UDID 列表 - 过期时间(免费账号 7 天) - Apple 的签名
|
每次签名都需要拉取最新 Profile,确保设备列表与证书是最新的。
2.6 IPA 重签名(浏览器内)
1 2 3 4 5 6 7 8 9 10 11 12
| const { signIPA } = await loadAltsignModule();
const signed = await signIPA({ ipaData, certificate: identity.certificate.publicKey, privateKey: identity.privateKey, provisioningProfile: provisioningProfile.data, bundleID: finalBundleId, adhoc: false, forceSign: true, });
|
底层由 zsign-wasm(C++ zsign 编译为 WebAssembly)在浏览器内完成,不需要上传 IPA 到服务器。
Part 3:端到端时序图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| 用户 前端 altsign.js Apple 服务器 │ │ │ │ │ 输入 Apple ID/密码 │ │ │ │────────────────────▶│ │ │ │ │ api.authenticate() │ │ │ │───────────────────▶│ │ │ │ │ POST init (SRP A) │ │ │ │───────────────────▶│ │ │ │◀── salt, B, c ─────│ │ │ │ 派生密码 key │ │ │ │ 计算 M1 │ │ │ │ POST complete (M1) │ │ │ │───────────────────▶│ │ │ │◀── M2, spd ────────│ │ │ │ 验证 M2 │ │ │ │ 解密 spd │ │ │ │ → adsid, idmsToken │ │ │ │ │ │ │ │ [au=trustedDevice] │ │ │ │ GET trusteddevice │ │ │ │───────────────────▶│ Apple 推送到设备 │◀──────────────────────────────────────────────────────────────│ (弹窗) │ 查看设备上的 6 位码 │ │ │ │ 输入验证码 │ │ │ │────────────────────▶│ submitDeviceCode() │ │ │ │───────────────────▶│ │ │ │ │ GET validate │ │ │ │ Header:security-code│ │ │ │───────────────────▶│ │ │ │◀── { ec: 0 } ──────│ │ │ │ │ │ │ │ 重新走完整 SRP 流程 │ │ │ │──────── (同上) ────▶│ │ │ │◀── spd (含 sk,c2) ─│ │ │ │ │ │ │ │ POST apptokens │ │ │ │───────────────────▶│ │ │ │◀── et (AES-GCM) ───│ │ │ │ 解密 → authToken │ │ │◀── session ────────│ │ │ │ │ │ │ │ [证书签发流程] │ │ │ │ fetchCertificates │ Developer Services │ │ │──────────────────────────────────────▶ │ │ │ addCertificate(CSR)│ │ │ │──────────────────────────────────────▶ │ │ │ registerDevice │ │ │ │──────────────────────────────────────▶ │ │ │ addAppId │ │ │ │──────────────────────────────────────▶ │ │ │ downloadProfile │ │ │ │──────────────────────────────────────▶ │ │ │ │ │ │ │ zsign-wasm 浏览器内重签名 │ │ │ │ │ │◀── 下载 signed.ipa ──│ │ │
|
Part 4:安全性注意事项
凭证流转路径
1 2 3 4 5 6 7 8 9 10
| Apple ID 密码 │ 只在 SRP 握手中使用,不传输明文 ▼ PBKDF2(SHA256(password), salt, iterations) │ ▼ SRP session key K │ ├──▶ 解密 spd → idmsToken(短期,仅用于 2FA 和 token 交换) └──▶ 解密 et → authToken(Xcode 作用域,存入 localStorage)
|
风险点
| 风险 |
说明 |
| 页面托管方 |
authToken 存在 localStorage,托管该页面的服务器理论上可通过 XSS 获取。用户应确认信任服务器。 |
| Anisette 复用 |
OTP 是一次性的,重用会导致 Apple 返回 -22421 错误。每次认证必须使用新的 Anisette 数据。 |
| 证书私钥 |
Apple 不存储私钥,只有本地 localStorage 有。清除 localStorage 会导致证书无法使用。 |
| Profile 7 天有效期 |
免费账号的 Provisioning Profile 7 天过期,过期后 App 无法启动,需重签。 |
| 协议无文档保证 |
所有端点均为逆向,Apple 可随时更改,无 SLA。 |
附录:关键常量来源
| 常量 |
值 |
来源 |
PROTOCOL_VERSION |
QH65B2 |
逆向 Xcode,也是 Developer Services base URL 的一部分 |
CLIENT_ID |
XABBG36SBA |
逆向 Xcode |
| SRP 群组参数 |
2048-bit |
逆向 akd daemon |
noUsernameInX |
true |
逆向 AltSign,Apple 的 SRP 非标准改动 |
| GCM magic |
0x58 0x59 0x5A (“XYZ”) |
逆向 AltSign Swift 实现 |
| GSA endpoint |
gsa.apple.com/grandslam/GsService2 |
抓包 iTunes/Xcode |