Apple Authentication Internals

First Post:
Last Update:
Word Count: 3.8k
Read Time: 18min

实验性文档 — 本文描述的协议来自社区逆向工程,Apple 从未公开任何相关 API 文档。
内容随 Apple 服务器端更新可能失效。


来源说明

来源 贡献
AltStore / AltSign (rileytestut) SRP 流程、spd 解密、证书/Profile 管理的最早完整 Swift 实现
Anisette-Server (Dadoum) Anisette headers (X-Apple-I-MD 等) 的逆向与说明
apple-auth (spaceship) (fastlane) Ruby 实现,字段注释详尽
altsign.js (lbr77) 本项目依赖的 JS/TS 移植
RFC 2945 / RFC 5054 SRP 协议标准(Apple 的实现有非标准改动)
macOS akd daemon 逆向 s2k / s2k_fo 密码派生方案

本文中的代码均来自 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
// apple-signing.ts — 每个请求都携带 Anisette 数据
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
密码派生 单一方案 服务器动态选择 s2ks2k_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 │

密码派生:s2ks2k_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
// auth.ts — derivePasswordKey()
async function derivePasswordKey(
password: string,
salt: Uint8Array,
iterations: number,
isS2K: boolean // true = s2k, false = s2k_fo
): Promise<Uint8Array> {
// Step 1:SHA-256 原始密码
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) {
// s2k:直接用 32 字节的 SHA-256 摘要作为 PBKDF2 输入
digest = hashBytes;
} else {
// s2k_fo(旧方案):先把 SHA-256 摘要 hex 编码为 64 字节 ASCII
// 再用这 64 字节作为 PBKDF2 输入
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;
}

// Step 2:PBKDF2-SHA-256,输出 32 字节
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
// auth.ts — createSessionKey()
async function createSessionKey(
sessionKey: Uint8Array,
keyName: string // "extra data key:" 或 "extra data iv:"
): 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
// auth.ts — authenticate()
const extraDataKey = await createSessionKey(sessionKey, 'extra data key:');
const extraDataIV = await createSessionKey(sessionKey, 'extra data iv:');

// AES-128-CBC 解密(前 16 字节作 IV)
const decryptedData = await decryptCBC(spd, extraDataKey, extraDataIV);
const decryptedPlist = parsePlist(new TextDecoder().decode(decryptedData));

// 解密后的 plist 包含:
const adsid = decryptedPlist['adsid']; // Apple DS ID(账号唯一标识)
const idmsToken = decryptedPlist['GsIdmsToken'];// 身份令牌,用于 2FA
const sk = atob(decryptedPlist['sk']); // Token 加密密钥
const c2 = atob(decryptedPlist['c']); // Token 请求 challenge

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
// auth.ts — authenticate()
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) {
// 2FA 通过后必须重新完整走一遍 SRP
// 原因:Apple 需要新的 Anisette OTP 才能颁发正式凭证
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
// auth-device-2fa.ts — submitDeviceCode()
const submitDeviceCode = async (code: string) => {
// Apple 通过自定义 Header 接收验证码,不是 POST body
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); // 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
// auth-phone-2fa.ts — submitSmsCode()
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 {
// Apple 有时通过 JSON ec 字段返回结果,而不是 HTTP 状态码
const parsed = JSON.parse(await resp.text()) as Record<string, unknown>;
if (parsed['ec'] !== undefined) ok = parsed['ec'] === 0;
} catch { /* 降级使用 HTTP 状态 */ }
if (!ok) throw new Error('SMS verification failed');
resolve(true);
};

为什么两种路径都提供相同的 TwoFactorContext

1
2
3
4
5
6
7
// types.ts
export interface TwoFactorContext {
submitDeviceCode: (code: string) => void; // 设备推送路径
trustedPhoneNumbers: TrustedPhoneNumber[]; // 可用手机号列表
requestSms: (phoneId: number) => Promise<void>; // 请求发送 SMS
submitSmsCode: (phoneId: number, code: string) => Promise<void>; // 提交 SMS 码
}

设计原则:UI 层不感知 au 类型

  • trustedDeviceSecondaryAuth:有受信设备 → 推送通知。但 Apple 同时允许 SMS 兜底,所以 device 路径也提供完整 SMS callbacks。
  • phoneSecondaryAuth:无受信设备 → 只有 SMS。submitDeviceCode 保留是为了接口对称,不影响逻辑。

UI 只根据 trustedPhoneNumbers.length 决定是否渲染 SMS 按钮。


1.5 App Token Exchange — 获取 Xcode 凭证

2FA 通过(或不需要 2FA)后,使用 spd 中的 skc2 交换一个限定作用域的 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>
}

解密 et:AES-GCM Wire Format

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
// auth.ts — decryptGCM()
async function decryptGCM(data: Uint8Array, key: Uint8Array) {
// 验证 "XYZ" magic
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, // AAD = magic bytes
tagLength: 128,
},
cryptoKey,
new Uint8Array([...ciphertext, ...tag]).buffer // Web Crypto 期望 ciphertext+tag 拼接
);
// 解密结果是 plist: { t: { "com.apple.gs.xcode.auth": { token: "..." } } }
}

Checksum:防重放

请求必须携带一个 HMAC 校验和,防止 token 请求被重放:

1
2
3
4
5
6
7
8
9
10
11
12
// auth.ts — createAppTokensChecksum()
// 消息 = "apptokens" || adsid || app1 || app2 ... (UTF-8,无分隔符)
async function createAppTokensChecksum(sk, adsid, apps) {
const parts = [
encode('apptokens'),
encode(adsid),
...apps.map(encode),
];
// 拼接所有部分,对整体做 HMAC-SHA256
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
// auth.ts — buildHeaders()
{
'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,
// + 全套 Anisette headers
}

Base URL: https://developerservices2.apple.com/services/QH65B2/
Client ID: XABBG36SBA(硬编码,逆向自 Xcode)

每个请求 body 包含随机 requestId(UUID)防重放:

1
2
3
4
5
6
7
// auth.ts — buildRequestBody()
{
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#10 CSR │
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
// apple-signing.ts — ensureSigningIdentity()
try {
created = await api.addCertificate(session, team, `webmuxd-${Date.now()}`);
} catch (error) {
const message = String(error);
// 错误码 7460 = "Maximum number of certificates reached"
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
// apple-signing.ts — ensureSigningIdentity()
const cached = loadCachedSigningIdentity(appleId, team.identifier);
if (cached) {
// 在服务器现存证书列表中找到对应 certId
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
// apple-signing.ts — ensureDeviceRegistered()

// 1. 先查询已注册设备,避免重复注册(Apple 不允许注册同 UDID 两次)
const devices = await api.fetchDevices(session, team);
const existed = devices.find(d => normalizeUdid(d.identifier) === normalizedUdid);
if (existed) return; // 已注册,跳过

// 2. 注册新设备
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
// apple-signing.ts — buildTeamScopedBundleId()
// "com.example.MyApp" + "ABCD1234" → "com.example.MyApp.ABCD1234"
function buildTeamScopedBundleId(baseBundleId: string, teamId: string): string {
const lowerBase = baseBundleId.toLowerCase();
const lowerTeam = teamId.toLowerCase();
// 如果已经以 Team ID 结尾,不重复追加
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
// apple-signing.ts — signIpaWithAppleContext()
const { signIPA } = await loadAltsignModule(); // 懒加载 zsign WASM

const signed = await signIPA({
ipaData, // 原始 IPA 字节
certificate: identity.certificate.publicKey, // DER 格式证书
privateKey: identity.privateKey, // RSA 私钥
provisioningProfile: provisioningProfile.data, // 签名后的 .mobileprovision
bundleID: finalBundleId, // 已追加 Team ID 的 Bundle ID
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