Skip to content

Commit

Permalink
Merge pull request #276 from RobotsAndPencils/hashcash
Browse files Browse the repository at this point in the history
Implement hashcash for AppleID Authentication
  • Loading branch information
MattKiazyk authored Mar 4, 2023
2 parents 55ca0a9 + 6982c2e commit f3aae60
Show file tree
Hide file tree
Showing 6 changed files with 302 additions and 70 deletions.
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 f3aae60

Please sign in to comment.