Skip to content

Commit 76bb3fb

Browse files
committed
Adds hashcash implementation
1 parent 29503ad commit 76bb3fb

5 files changed

Lines changed: 129 additions & 188 deletions

File tree

Xcodes/AppleAPI/Sources/AppleAPI/Client.swift

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,22 @@ public class Client {
1414
return Current.network.dataTask(with: URLRequest.itcServiceKey)
1515
.map(\.data)
1616
.decode(type: ServiceKeyResponse.self, decoder: JSONDecoder())
17-
.flatMap { serviceKeyResponse -> AnyPublisher<URLSession.DataTaskPublisher.Output, Swift.Error> in
17+
.flatMap { serviceKeyResponse -> AnyPublisher<(String, String), Swift.Error> in
1818
serviceKey = serviceKeyResponse.authServiceKey
19-
return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password))
20-
.mapError { $0 as Swift.Error }
19+
20+
// Fixes issue https://github.com/RobotsAndPencils/XcodesApp/issues/360
21+
// On 2023-02-23, Apple added a custom implementation of hashcash to their auth flow
22+
// Without this addition, Apple ID's would get set to locked
23+
return self.loadHashcash(accountName: accountName, serviceKey: serviceKey)
24+
.map { return (serviceKey, $0)}
2125
.eraseToAnyPublisher()
2226
}
27+
.flatMap { (serviceKey, hashcash) -> AnyPublisher<URLSession.DataTaskPublisher.Output, Swift.Error> in
28+
29+
return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password, hashcash: hashcash))
30+
.mapError { $0 as Swift.Error }
31+
.eraseToAnyPublisher()
32+
}
2333
.flatMap { result -> AnyPublisher<AuthenticationState, Swift.Error> in
2434
let (data, response) = result
2535
return Just(data)
@@ -56,6 +66,44 @@ public class Client {
5666
.mapError { $0 as Swift.Error }
5767
.eraseToAnyPublisher()
5868
}
69+
70+
func loadHashcash(accountName: String, serviceKey: String) -> AnyPublisher<String, Swift.Error> {
71+
72+
Result {
73+
try URLRequest.federate(account: accountName, serviceKey: serviceKey)
74+
}
75+
.publisher
76+
.flatMap { request in
77+
Current.network.dataTask(with: request)
78+
.mapError { $0 as Error }
79+
.tryMap { (data, response) throws -> (String) in
80+
guard let urlResponse = response as? HTTPURLResponse else {
81+
throw AuthenticationError.invalidSession
82+
}
83+
switch urlResponse.statusCode {
84+
case 200..<300:
85+
86+
let httpResponse = response as! HTTPURLResponse
87+
guard let bitsString = httpResponse.allHeaderFields["X-Apple-HC-Bits"] as? String, let bits = UInt(bitsString) else {
88+
throw AuthenticationError.invalidHashcash
89+
}
90+
guard let challenge = httpResponse.allHeaderFields["X-Apple-HC-Challenge"] as? String else {
91+
throw AuthenticationError.invalidHashcash
92+
}
93+
guard let hashcash = Hashcash().mint(resource: challenge, bits: bits) else {
94+
throw AuthenticationError.invalidHashcash
95+
}
96+
return (hashcash)
97+
case 400, 401:
98+
throw AuthenticationError.invalidHashcash
99+
case let code:
100+
throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse)
101+
}
102+
}
103+
}
104+
.eraseToAnyPublisher()
105+
106+
}
59107

60108
func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher<AuthenticationState, Swift.Error> {
61109
let httpResponse = response as! HTTPURLResponse
@@ -190,6 +238,7 @@ public enum AuthenticationState: Equatable {
190238

191239
public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
192240
case invalidSession
241+
case invalidHashcash
193242
case invalidUsernameOrPassword(username: String)
194243
case incorrectSecurityCode
195244
case unexpectedSignInResponse(statusCode: Int, message: String?)
@@ -206,6 +255,8 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
206255
switch self {
207256
case .invalidSession:
208257
return "Your authentication session is invalid. Try signing in again."
258+
case .invalidHashcash:
259+
return "Could not create a hashcash for the session."
209260
case .invalidUsernameOrPassword:
210261
return "Invalid username and password combination."
211262
case .incorrectSecurityCode:

Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift

Lines changed: 45 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,44 @@ import Foundation
99
import CryptoKit
1010
import CommonCrypto
1111

12+
/*
13+
# This App Store Connect hashcash spec was generously donated by...
14+
#
15+
# __ _
16+
# __ _ _ __ _ __ / _|(_) __ _ _ _ _ __ ___ ___
17+
# / _` || '_ \ | '_ \ | |_ | | / _` || | | || '__|/ _ \/ __|
18+
# | (_| || |_) || |_) || _|| || (_| || |_| || | | __/\__ \
19+
# \__,_|| .__/ | .__/ |_| |_| \__, | \__,_||_| \___||___/
20+
# |_| |_| |___/
21+
#
22+
#
23+
*/
1224
public struct Hashcash {
13-
25+
/// A function to returned a minted hash, using a bit and resource string
26+
///
27+
/**
28+
X-APPLE-HC: 1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373
29+
^ ^ ^ ^ ^
30+
| | | | +-- Counter
31+
| | | +-- Resource
32+
| | +-- Date YYMMDD[hhmm[ss]]
33+
| +-- Bits (number of leading zeros)
34+
+-- Version
35+
36+
We can't use an off-the-shelf Hashcash because Apple's implementation is not quite the same as the spec/convention.
37+
1. The spec calls for a nonce called "Rand" to be inserted between the Ext and Counter. They don't do that at all.
38+
2. The Counter conventionally encoded as base-64 but Apple just uses the decimal number's string representation.
39+
40+
Iterate from Counter=0 to Counter=N finding an N that makes the SHA1(X-APPLE-HC) lead with Bits leading zero bits
41+
We get the "Resource" from the X-Apple-HC-Challenge header and Bits from X-Apple-HC-Bits
42+
*/
43+
/// - Parameters:
44+
/// - resource: a string to be used for minting
45+
/// - bits: grabbed from `X-Apple-HC-Bits` header
46+
/// - date: Default uses Date() otherwise used for testing to check.
47+
/// - Returns: A String hash to use in `X-Apple-HC` header on /signin
1448
public func mint(resource: String,
15-
bits: UInt = 20,
16-
ext: String = "",
17-
saltCharacters: UInt = 16,
18-
stampSeconds: Bool = true,
49+
bits: UInt = 10,
1950
date: String? = nil) -> String? {
2051

2152
let ver = "1"
@@ -25,103 +56,41 @@ public struct Hashcash {
2556
ts = date
2657
} else {
2758
let formatter = DateFormatter()
28-
formatter.dateFormat = stampSeconds ? "yyMMddHHmmss" : "yyMMdd"
59+
formatter.dateFormat = "yyMMddHHmmss"
2960
ts = formatter.string(from: Date())
3061
}
3162

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

3465
var counter = 0
35-
let hexDigits = Int(ceil((Double(bits) / 4)))
36-
let zeros = String(repeating: "0", count: hexDigits)
3766

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

44-
if digest.prefix(hexDigits) == zeros {
73+
if digest == bits {
4574
return "\(challenge):\(counter)"
4675
}
4776
counter += 1
4877
}
4978
}
50-
51-
/**
52-
Checks whether a stamp is valid
53-
- parameter stamp: stamp to check e.g. 1:16:040922:foo::+ArSrtKd:164b3
54-
- parameter resource: resource to check against
55-
- parameter bits: minimum bit value to check
56-
- parameter expiration: number of seconds old the stamp may be
57-
- returns: true if stamp is valid
58-
*/
59-
public func check(stamp: String,
60-
resource: String? = nil,
61-
bits: UInt,
62-
expiration: UInt? = nil) -> Bool {
63-
64-
guard let stamped = Stamp(stamp: stamp) else {
65-
print("Invalid stamp format")
66-
return false
67-
}
68-
69-
if let res = resource, res != stamped.resource {
70-
print("Resources do not match")
71-
return false
72-
}
73-
74-
var count = bits
75-
if let claim = stamped.claim {
76-
if bits > claim {
77-
return false
78-
} else {
79-
count = claim
80-
}
81-
}
82-
83-
if let expiration = expiration {
84-
let goodUntilDate = Date(timeIntervalSinceNow: -TimeInterval(expiration))
85-
if (stamped.date < goodUntilDate) {
86-
print("Stamp expired")
87-
return false
88-
}
89-
}
90-
91-
guard let digest = stamp.sha1 else {
92-
return false
93-
}
94-
95-
let hexDigits = Int(ceil((Double(count) / 4)))
96-
return digest.hasPrefix(String(repeating: "0", count: hexDigits))
97-
}
98-
99-
/**
100-
Generates random string of chosen length
101-
- parameter length: length of random string
102-
- returns: random string
103-
*/
104-
internal func salt(length: UInt) -> String {
105-
let allowedCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+/="
106-
var result = ""
107-
108-
for _ in 0..<length {
109-
let randomValue = arc4random_uniform(UInt32(allowedCharacters.count))
110-
result += "\(allowedCharacters[allowedCharacters.index(allowedCharacters.startIndex, offsetBy: Int(randomValue))])"
111-
}
112-
return result
113-
}
11479
}
11580

11681
extension String {
117-
var sha1: String? {
82+
var sha1: Int? {
83+
11884
let data = Data(self.utf8)
11985
var digest = [UInt8](repeating: 0, count:Int(CC_SHA1_DIGEST_LENGTH))
12086
data.withUnsafeBytes {
12187
_ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest)
12288
}
123-
let hexBytes = digest.map { String(format: "%02x", $0) }
124-
return hexBytes.joined()
89+
let bigEndianValue = digest.withUnsafeBufferPointer {
90+
($0.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: 1) { $0 })
91+
}.pointee
92+
let value = UInt32(bigEndian: bigEndianValue)
93+
return value.leadingZeroBitCount
12594
}
12695
}
12796

Xcodes/AppleAPI/Sources/AppleAPI/Stamp.swift

Lines changed: 0 additions & 101 deletions
This file was deleted.

Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public extension URL {
77
static let requestSecurityCode = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/phone")!
88
static func submitSecurityCode(_ code: SecurityCode) -> URL { URL(string: "https://idmsa.apple.com/appleauth/auth/verify/\(code.urlPathComponent)/securitycode")! }
99
static let trust = URL(string: "https://idmsa.apple.com/appleauth/auth/2sv/trust")!
10+
static let federate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate")!
1011
static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")!
1112
}
1213

@@ -15,7 +16,7 @@ public extension URLRequest {
1516
return URLRequest(url: .itcServiceKey)
1617
}
1718

18-
static func signIn(serviceKey: String, accountName: String, password: String) -> URLRequest {
19+
static func signIn(serviceKey: String, accountName: String, password: String, hashcash: String) -> URLRequest {
1920
struct Body: Encodable {
2021
let accountName: String
2122
let password: String
@@ -27,6 +28,7 @@ public extension URLRequest {
2728
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
2829
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
2930
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
31+
request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash
3032
request.allHTTPHeaderFields?["Accept"] = "application/json, text/javascript"
3133
request.httpMethod = "POST"
3234
request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName, password: password))
@@ -117,4 +119,21 @@ public extension URLRequest {
117119
static var olympusSession: URLRequest {
118120
return URLRequest(url: .olympusSession)
119121
}
122+
123+
static func federate(account: String, serviceKey: String) throws -> URLRequest {
124+
struct FederateRequest: Encodable {
125+
let accountName: String
126+
let rememberMe: Bool
127+
}
128+
var request = URLRequest(url: .signIn)
129+
request.allHTTPHeaderFields?["Accept"] = "application/json"
130+
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
131+
request.httpMethod = "GET"
132+
133+
// let encoder = JSONEncoder()
134+
// encoder.outputFormatting = .withoutEscapingSlashes
135+
// request.httpBody = try encoder.encode(FederateRequest(accountName: account, rememberMe: true))
136+
137+
return request
138+
}
120139
}

0 commit comments

Comments
 (0)