SMS 2FA fails with HTTP 412 when the trusted phone number ID isn't 1

オープン
#424 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

まだ誰も着手していません。

評価

難易度
3/5
見積もり時間
1〜2日
初心者へのやさしさ
48/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
swift

調査の方向性

Start with Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift and the SMS request types in Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamTrustedDeviceRequest.swift and GrandSlamValidateRequest.swift. Trace how HTTP 412 responses and trusted phone number data are handled, then check existing tests for these requests. Done means SMS login works when the trusted phone number ID is not 1, including code validation; the issue reports a successful manual test but names no automated test.

索引モデルが issue の本文から書いたものです。

説明

Description

SMS 2FA login fails on accounts whose trusted phone number doesn't have ID 1. performSMSAuth hardcodes phoneNumberID = 1, so Apple rejects the request and no SMS is sent:

Logging in...
Error: PUT https://gsa.apple.com/auth/verify/phone failed: HTTP 412. Details:

The response lists the valid numbers (personal data removed):

{
  "success": 0,
  "trustedPhoneNumbers": [
    { "nonFTEU": true, "pushMode": "sms", "id": 2 }
  ],
  "mode": "sms"
}

Steps to reproduce

  1. Use an Apple ID with no Apple devices, whose only trusted number has an ID other than 1.
  2. Run xtool auth login and choose 1: Password.
  3. Enter the Apple ID and password.

Environment

  • Host OS: Linux, Ubuntu 26.04.1 LTS, x86_64
  • xtool: commit bec5aa106 (includes #416), debug build
  • Swift version: 6.4.1
  • iOS device version: n/a (no iOS device on the account)

Proposed fix

On a 412, read trustedPhoneNumbers from the response and retry with the first entry's id and nonFTEU. Use the same values when validating the code:

} catch let error as HTTPResponseError where error.status.code == 412 {
    // the ID is not always 1; Apple lists the valid ones in the 412 response
    guard let trusted = error.body
        .flatMap({ try? JSONDecoder().decode(PhoneVerificationResponse.self, from: Data($0.utf8)) })?
        .trustedPhoneNumbers.first
    else { throw error }
    phoneNumber = trusted
    try await sendSMS(to: phoneNumber)
}

The full patch is below.

Full patch
diff --git a/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift b/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift
index 5a41e02..a0f1dde 100644
--- a/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift
+++ b/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift
@@ -25,11 +25,37 @@ struct GrandSlamTwoFactorAuthenticateOperation {
     let loginData: GrandSlamLoginData
     unowned let delegate: TwoFactorAuthDelegate
 
+    private struct TrustedPhoneNumber: Decodable {
+        let id: Int
+        var nonFTEU: Bool?
+    }
+
+    private struct PhoneVerificationResponse: Decodable {
+        let trustedPhoneNumbers: [TrustedPhoneNumber]
+    }
+
+    private func sendSMS(to phoneNumber: TrustedPhoneNumber) async throws {
+        try await client.send(GrandSlamSMSAuthRequest(
+            loginData: loginData,
+            phoneNumberID: phoneNumber.id,
+            nonFTEU: phoneNumber.nonFTEU
+        ))
+    }
+
     private func performSMSAuth() async throws {
-        // TODO: parse phone number list from o=complete req?
-        let phoneNumberID = 1
+        var phoneNumber = TrustedPhoneNumber(id: 1)
         do {
-            try await client.send(GrandSlamSMSAuthRequest(loginData: loginData, phoneNumberID: phoneNumberID))
+            do {
+                try await sendSMS(to: phoneNumber)
+            } catch let error as HTTPResponseError where error.status.code == 412 {
+                // the ID is not always 1; Apple lists the valid ones in the 412 response
+                guard let trusted = error.body
+                    .flatMap({ try? JSONDecoder().decode(PhoneVerificationResponse.self, from: Data($0.utf8)) })?
+                    .trustedPhoneNumbers.first
+                else { throw error }
+                phoneNumber = trusted
+                try await sendSMS(to: phoneNumber)
+            }
         } catch let error as HTTPResponseError where error.status.code == 403 {
             d.out.print("""
             warning: Could not send verification code via SMS. Falling back to trusted device authentication...
@@ -37,15 +63,16 @@ struct GrandSlamTwoFactorAuthenticateOperation {
             try await performTrustedDeviceAuth()
             return
         }
-        try await validateCode(phoneNumberID: phoneNumberID)
+        try await validateCode(phoneNumber: phoneNumber)
     }
 
-    private func validateCode(phoneNumberID: Int? = nil) async throws {
+    private func validateCode(phoneNumber: TrustedPhoneNumber? = nil) async throws {
         guard let code = await self.delegate.fetchCode() else { throw Error.userCancelled }
-        let request: any GrandSlamRequest = if let phoneNumberID {
+        let request: any GrandSlamRequest = if let phoneNumber {
             GrandSlamValidateSMSRequest(
                 loginData: loginData,
-                phoneNumberID: phoneNumberID,
+                phoneNumberID: phoneNumber.id,
+                nonFTEU: phoneNumber.nonFTEU,
                 verificationCode: code
             )
         } else {
diff --git a/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamTrustedDeviceRequest.swift b/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamTrustedDeviceRequest.swift
index 628ba9f..545a7f4 100644
--- a/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamTrustedDeviceRequest.swift
+++ b/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamTrustedDeviceRequest.swift
@@ -39,6 +39,7 @@ struct GrandSlamSMSAuthRequest: GrandSlamTwoFactorRequest {
 
     let loginData: GrandSlamLoginData
     let phoneNumberID: Int
+    var nonFTEU: Bool?
 
     var extraHeaders: [String: String] {
         [
@@ -47,9 +48,15 @@ struct GrandSlamSMSAuthRequest: GrandSlamTwoFactorRequest {
         ]
     }
 
+    private var phoneNumber: [String: Any] {
+        var phoneNumber: [String: Any] = ["id": phoneNumberID]
+        if let nonFTEU { phoneNumber["nonFTEU"] = nonFTEU }
+        return phoneNumber
+    }
+
     func content(anisetteData: AnisetteData) -> GrandSlamRequestContent {
         .put([
-            "phoneNumber": ["id": phoneNumberID],
+            "phoneNumber": phoneNumber,
             "mode": "sms",
         ], type: .json)
     }
diff --git a/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamValidateRequest.swift b/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamValidateRequest.swift
index b93c614..b9f1e13 100644
--- a/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamValidateRequest.swift
+++ b/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamValidateRequest.swift
@@ -34,6 +34,7 @@ struct GrandSlamValidateSMSRequest: GrandSlamTwoFactorRequest {
 
     let loginData: GrandSlamLoginData
     let phoneNumberID: Int
+    var nonFTEU: Bool?
     let verificationCode: String
 
     var extraHeaders: [String: String] {
@@ -43,10 +44,16 @@ struct GrandSlamValidateSMSRequest: GrandSlamTwoFactorRequest {
         ]
     }
 
+    private var phoneNumber: [String: Any] {
+        var phoneNumber: [String: Any] = ["id": phoneNumberID]
+        if let nonFTEU { phoneNumber["nonFTEU"] = nonFTEU }
+        return phoneNumber
+    }
+
     func content(anisetteData: AnisetteData) -> GrandSlamRequestContent {
         .post([
             "securityCode": ["code": verificationCode],
-            "phoneNumber": ["id": phoneNumberID],
+            "phoneNumber": phoneNumber,
             "mode": "sms",
         ], type: .json)
     }

Testing

With this patch, the SMS arrived and login completed on the affected account.

Not tested:

  • Whether nonFTEU is actually required
  • Accounts with more than one trusted number (the patch always picks the first)

The diagnosis and patch were written with Claude Code. I tested the login on my account.

主要言語
Swift
スター
5.5k
フォーク
163
平均マージ
1日 12時間
マージ済み PR(30日)
32

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

xtool-org/xtool のほかの issue

xtool-org/xtool の issue をすべて見る

似ている issue

Swift の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。