Skip to content

Commit

Permalink
Merge tag '1.3.0' into xip-multivolume-expasion
Browse files Browse the repository at this point in the history
# Conflicts:
#	Sources/xcodes/App.swift
  • Loading branch information
juanjonol committed Apr 7, 2023
2 parents 5beeccf + 95a9619 commit 85b1219
Show file tree
Hide file tree
Showing 22 changed files with 669 additions and 145 deletions.
9 changes: 9 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@
"revision" : "087c91fedc110f9f833b14ef4c32745dabca8913",
"version" : "1.0.3"
}
},
{
"identity" : "yams",
"kind" : "remoteSourceControl",
"location" : "https://github.com/jpsim/Yams",
"state" : {
"revision" : "01835dc202670b5bb90d07f3eae41867e9ed29f6",
"version" : "5.0.1"
}
}
],
"version" : 2
Expand Down
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ let package = Package(
.package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", .upToNextMinor(from: "3.2.0")),
.package(url: "https://github.com/xcodereleases/data", revision: "fcf527b187817f67c05223676341f3ab69d4214d"),
.package(url: "https://github.com/onevcat/Rainbow.git", .upToNextMinor(from: "3.2.0")),
.package(url: "https://github.com/jpsim/Yams", .upToNextMinor(from: "5.0.1")),
],
targets: [
.executableTarget(
Expand Down Expand Up @@ -49,6 +50,7 @@ let package = Package(
"Version",
.product(name: "XCModel", package: "data"),
"Rainbow",
"Yams",
]),
.testTarget(
name: "XcodesKitTests",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ You'll need Xcode 13 in order to build and run xcodes.

<details>
<summary>Using Xcode</summary>
Even though xcodes is a command-line app, lll of the normal functionality works in Xcode, like building, running, and running tests. You can even type text into Xcode's console when it prompts you for input like your Apple ID or 2FA code.
Even though xcodes is a command-line app, all of the normal functionality works in Xcode, like building, running, and running tests. You can even type text into Xcode's console when it prompts you for input like your Apple ID or 2FA code.

When running xcodes from Xcode, if you want to run a particular command or pass some arguments, you can hold the option key to present a sheet with more options. This means you'd use <kbd>Option</kbd> + <kbd>Command</kbd> + <kbd>R</kbd> or hold <kbd>Option</kbd> while clicking the Run button. Here you can add, remove, and toggle arguments that will be passed to xcodes when it's launched.

Expand Down
85 changes: 59 additions & 26 deletions Sources/AppleAPI/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ public class Client {
case appleIDAndPrivacyAcknowledgementRequired
case noTrustedPhoneNumbers
case notAuthenticated

case invalidHashcash

public var errorDescription: String? {
switch self {
case .invalidUsernameOrPassword(let username):
Expand All @@ -30,6 +31,8 @@ public class Client {
return "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication. See https://support.apple.com/en-ca/HT204915."
case .notAuthenticated:
return "You are already signed out"
case .invalidHashcash:
return "Could not create a hashcash for the session."
default:
return String(describing: self)
}
Expand All @@ -39,48 +42,52 @@ public class Client {
/// Use the olympus session endpoint to see if the existing session is still valid
public func validateSession() -> Promise<Void> {
return Current.network.dataTask(with: URLRequest.olympusSession)
.done { data, response in
guard
let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
jsonObject["provider"] != nil
else { throw Error.invalidSession }
}
.done { data, response in
guard
let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
jsonObject["provider"] != nil
else { throw Error.invalidSession }
}
}

public func login(accountName: String, password: String) -> Promise<Void> {
var serviceKey: String!

return firstly { () -> Promise<(data: Data, response: URLResponse)> in
Current.network.dataTask(with: URLRequest.itcServiceKey)
}
.then { (data, _) -> Promise<(data: Data, response: URLResponse)> in
.then { (data, _) -> Promise<(serviceKey: String, hashcash: String)> in
struct ServiceKeyResponse: Decodable {
let authServiceKey: String
}

let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data)
serviceKey = response.authServiceKey

return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password))

return self.loadHashcash(accountName: accountName, serviceKey: serviceKey).map { (serviceKey, $0) }
}
.then { (serviceKey, hashcash) -> Promise<(data: Data, response: URLResponse)> in

return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password, hashcash: hashcash))
}
.then { (data, response) -> Promise<Void> in
struct SignInResponse: Decodable {
let authType: String?
let serviceErrors: [ServiceError]?

struct ServiceError: Decodable, CustomStringConvertible {
let code: String
let message: String

var description: String {
return "\(code): \(message)"
}
}
}

let httpResponse = response as! HTTPURLResponse
let responseBody = try JSONDecoder().decode(SignInResponse.self, from: data)

switch httpResponse.statusCode {
case 200:
return Current.network.dataTask(with: URLRequest.olympusSession).asVoid()
Expand All @@ -96,12 +103,12 @@ public class Client {
}
}
}

func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> Promise<Void> {
let httpResponse = response as! HTTPURLResponse
let sessionID = (httpResponse.allHeaderFields["X-Apple-ID-Session-Id"] as! String)
let scnt = (httpResponse.allHeaderFields["scnt"] as! String)

return firstly { () -> Promise<AuthOptionsResponse> in
return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
.map { try JSONDecoder().decode(AuthOptionsResponse.self, from: $0.data) }
Expand All @@ -123,7 +130,7 @@ public class Client {

func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> Promise<Void> {
Current.logging.log("Two-factor authentication is enabled for this account.\n")

// SMS was sent automatically
if authOptions.smsAutomaticallySent {
return firstly { () throws -> Promise<(data: Data, response: URLResponse)> in
Expand All @@ -134,10 +141,10 @@ public class Client {
.then { (data, response) -> Promise<Void> in
self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
}
// SMS wasn't sent automatically because user needs to choose a phone to send to
// SMS wasn't sent automatically because user needs to choose a phone to send to
} else if authOptions.canFallBackToSMS {
return handleWithPhoneNumberSelection(authOptions: authOptions, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
// Code is shown on trusted devices
// Code is shown on trusted devices
} else {
let code = Current.shell.readLine("""
Enter "sms" without quotes to exit this prompt and choose a phone number to send an SMS security code to.
Expand All @@ -147,11 +154,11 @@ public class Client {
if code == "sms" {
return handleWithPhoneNumberSelection(authOptions: authOptions, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
}

return firstly {
Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: .device(code: code)))
.validateSecurityCodeResponse()

}
.then { (data, response) -> Promise<Void> in
self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
Expand All @@ -172,7 +179,7 @@ public class Client {
trustedPhoneNumbers.enumerated().forEach { (index, phoneNumber) in
Current.logging.log("\(index + 1): \(phoneNumber.numberWithDialCode)")
}

let possibleSelectionNumberString = Current.shell.readLine("Select a trusted phone number to receive a code via SMS: ")
guard
let selectionNumberString = possibleSelectionNumberString,
Expand All @@ -181,7 +188,7 @@ public class Client {
else {
throw Error.invalidPhoneNumberIndex(min: 1, max: trustedPhoneNumbers.count, given: possibleSelectionNumberString)
}

return .value(trustedPhoneNumbers[selectionNumber - 1])
}
.recover { error throws -> Promise<AuthOptionsResponse.TrustedPhoneNumber> in
Expand Down Expand Up @@ -220,6 +227,32 @@ public class Client {
self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
}
}

// Fixes issue https://github.com/RobotsAndPencils/XcodesApp/issues/360
// On 2023-02-23, Apple added a custom implementation of hashcash to their auth flow
// Without this addition, Apple ID's would get set to locked
func loadHashcash(accountName: String, serviceKey: String) -> Promise<String> {
return firstly{ () -> Promise<(data: Data, response: URLResponse)> in
Current.network.dataTask(with: try URLRequest.federate(account: accountName, serviceKey: serviceKey))
}
.then { (_ response) -> Promise<String> in
guard let urlResponse = response.response as? HTTPURLResponse else {
throw Client.Error.invalidSession
}

guard let bitString = urlResponse.allHeaderFields["X-Apple-HC-Bits"] as? String, let bits = UInt(bitString) else {
throw Client.Error.invalidHashcash
}
guard let challenge = urlResponse.allHeaderFields["X-Apple-HC-Challenge"] as? String else {
throw Client.Error.invalidHashcash
}
guard let hashcash = Hashcash().mint(resource: challenge, bits: bits) else {
throw Client.Error.invalidHashcash
}

return .value(hashcash)
}
}
}

public extension Promise where T == (data: Data, response: URLResponse) {
Expand Down
95 changes: 95 additions & 0 deletions Sources/AppleAPI/Hashcash.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//
// Hashcash.swift
//
//
// Created by Matt Kiazyk on 2023-02-23.
//

import Foundation
import CryptoKit
import CommonCrypto

/*
# This App Store Connect hashcash spec was generously donated by...
#
# __ _
# __ _ _ __ _ __ / _|(_) __ _ _ _ _ __ ___ ___
# / _` || '_ \ | '_ \ | |_ | | / _` || | | || '__|/ _ \/ __|
# | (_| || |_) || |_) || _|| || (_| || |_| || | | __/\__ \
# \__,_|| .__/ | .__/ |_| |_| \__, | \__,_||_| \___||___/
# |_| |_| |___/
#
#
*/
public struct Hashcash {
/// A function to returned a minted hash, using a bit and resource string
///
/**
X-APPLE-HC: 1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373
^ ^ ^ ^ ^
| | | | +-- Counter
| | | +-- Resource
| | +-- Date YYMMDD[hhmm[ss]]
| +-- Bits (number of leading zeros)
+-- Version

We can't use an off-the-shelf Hashcash because Apple's implementation is not quite the same as the spec/convention.
1. The spec calls for a nonce called "Rand" to be inserted between the Ext and Counter. They don't do that at all.
2. The Counter conventionally encoded as base-64 but Apple just uses the decimal number's string representation.

Iterate from Counter=0 to Counter=N finding an N that makes the SHA1(X-APPLE-HC) lead with Bits leading zero bits
We get the "Resource" from the X-Apple-HC-Challenge header and Bits from X-Apple-HC-Bits
*/
/// - Parameters:
/// - resource: a string to be used for minting
/// - bits: grabbed from `X-Apple-HC-Bits` header
/// - date: Default uses Date() otherwise used for testing to check.
/// - Returns: A String hash to use in `X-Apple-HC` header on /signin
public func mint(resource: String,
bits: UInt = 10,
date: String? = nil) -> String? {

let ver = "1"

var ts: String
if let date = date {
ts = date
} else {
let formatter = DateFormatter()
formatter.dateFormat = "yyMMddHHmmss"
ts = formatter.string(from: Date())
}

let challenge = "\(ver):\(bits):\(ts):\(resource):"

var counter = 0

while true {
guard let digest = ("\(challenge):\(counter)").sha1 else {
print("ERROR: Can't generate SHA1 digest")
return nil
}

if digest == bits {
return "\(challenge):\(counter)"
}
counter += 1
}
}
}

extension String {
var sha1: Int? {

let data = Data(self.utf8)
var digest = [UInt8](repeating: 0, count:Int(CC_SHA1_DIGEST_LENGTH))
data.withUnsafeBytes {
_ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest)
}
let bigEndianValue = digest.withUnsafeBufferPointer {
($0.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: 1) { $0 })
}.pointee
let value = UInt32(bigEndian: bigEndianValue)
return value.leadingZeroBitCount
}
}
14 changes: 13 additions & 1 deletion Sources/AppleAPI/URLRequest+Apple.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation

extension URL {
static let itcServiceKey = URL(string: "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com")!
// uses the get on signin to get the appropriate headers
static let signIn = URL(string: "https://idmsa.apple.com/appleauth/auth/signin")!
static let authOptions = URL(string: "https://idmsa.apple.com/appleauth/auth")!
static let requestSecurityCode = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/phone")!
Expand All @@ -15,7 +16,7 @@ extension URLRequest {
return URLRequest(url: .itcServiceKey)
}

static func signIn(serviceKey: String, accountName: String, password: String) -> URLRequest {
static func signIn(serviceKey: String, accountName: String, password: String, hashcash: String) -> URLRequest {
struct Body: Encodable {
let accountName: String
let password: String
Expand All @@ -28,6 +29,7 @@ extension URLRequest {
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["Accept"] = "application/json, text/javascript"
request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash
request.httpMethod = "POST"
request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName, password: password))
return request
Expand Down Expand Up @@ -117,4 +119,14 @@ extension URLRequest {
static var olympusSession: URLRequest {
return URLRequest(url: .olympusSession)
}

/// Federate the sign in to get the X-Apple-HC header keys in order to properly mint a hashcash during regular signin
static func federate(account: String, serviceKey: String) throws -> URLRequest {
var request = URLRequest(url: .signIn)
request.allHTTPHeaderFields?["Accept"] = "application/json"
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.httpMethod = "GET"

return request
}
}
Loading

0 comments on commit 85b1219

Please sign in to comment.