mirror of
https://github.com/XcodesOrg/XcodesApp.git
synced 2026-03-25 08:55:46 +00:00
Adds hashcash implementation
This commit is contained in:
parent
29503ad9cf
commit
76bb3fbae0
5 changed files with 129 additions and 188 deletions
|
|
@ -14,12 +14,22 @@ public class Client {
|
|||
return Current.network.dataTask(with: URLRequest.itcServiceKey)
|
||||
.map(\.data)
|
||||
.decode(type: ServiceKeyResponse.self, decoder: JSONDecoder())
|
||||
.flatMap { serviceKeyResponse -> AnyPublisher<URLSession.DataTaskPublisher.Output, Swift.Error> in
|
||||
.flatMap { serviceKeyResponse -> AnyPublisher<(String, String), Swift.Error> in
|
||||
serviceKey = serviceKeyResponse.authServiceKey
|
||||
return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password))
|
||||
.mapError { $0 as Swift.Error }
|
||||
|
||||
// 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
|
||||
return self.loadHashcash(accountName: accountName, serviceKey: serviceKey)
|
||||
.map { return (serviceKey, $0)}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.flatMap { (serviceKey, hashcash) -> AnyPublisher<URLSession.DataTaskPublisher.Output, Swift.Error> in
|
||||
|
||||
return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password, hashcash: hashcash))
|
||||
.mapError { $0 as Swift.Error }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.flatMap { result -> AnyPublisher<AuthenticationState, Swift.Error> in
|
||||
let (data, response) = result
|
||||
return Just(data)
|
||||
|
|
@ -56,6 +66,44 @@ public class Client {
|
|||
.mapError { $0 as Swift.Error }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func loadHashcash(accountName: String, serviceKey: String) -> AnyPublisher<String, Swift.Error> {
|
||||
|
||||
Result {
|
||||
try URLRequest.federate(account: accountName, serviceKey: serviceKey)
|
||||
}
|
||||
.publisher
|
||||
.flatMap { request in
|
||||
Current.network.dataTask(with: request)
|
||||
.mapError { $0 as Error }
|
||||
.tryMap { (data, response) throws -> (String) in
|
||||
guard let urlResponse = response as? HTTPURLResponse else {
|
||||
throw AuthenticationError.invalidSession
|
||||
}
|
||||
switch urlResponse.statusCode {
|
||||
case 200..<300:
|
||||
|
||||
let httpResponse = response as! HTTPURLResponse
|
||||
guard let bitsString = httpResponse.allHeaderFields["X-Apple-HC-Bits"] as? String, let bits = UInt(bitsString) else {
|
||||
throw AuthenticationError.invalidHashcash
|
||||
}
|
||||
guard let challenge = httpResponse.allHeaderFields["X-Apple-HC-Challenge"] as? String else {
|
||||
throw AuthenticationError.invalidHashcash
|
||||
}
|
||||
guard let hashcash = Hashcash().mint(resource: challenge, bits: bits) else {
|
||||
throw AuthenticationError.invalidHashcash
|
||||
}
|
||||
return (hashcash)
|
||||
case 400, 401:
|
||||
throw AuthenticationError.invalidHashcash
|
||||
case let code:
|
||||
throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
}
|
||||
|
||||
func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher<AuthenticationState, Swift.Error> {
|
||||
let httpResponse = response as! HTTPURLResponse
|
||||
|
|
@ -190,6 +238,7 @@ public enum AuthenticationState: Equatable {
|
|||
|
||||
public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
|
||||
case invalidSession
|
||||
case invalidHashcash
|
||||
case invalidUsernameOrPassword(username: String)
|
||||
case incorrectSecurityCode
|
||||
case unexpectedSignInResponse(statusCode: Int, message: String?)
|
||||
|
|
@ -206,6 +255,8 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
|
|||
switch self {
|
||||
case .invalidSession:
|
||||
return "Your authentication session is invalid. Try signing in again."
|
||||
case .invalidHashcash:
|
||||
return "Could not create a hashcash for the session."
|
||||
case .invalidUsernameOrPassword:
|
||||
return "Invalid username and password combination."
|
||||
case .incorrectSecurityCode:
|
||||
|
|
|
|||
|
|
@ -9,13 +9,44 @@ 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 = 20,
|
||||
ext: String = "",
|
||||
saltCharacters: UInt = 16,
|
||||
stampSeconds: Bool = true,
|
||||
bits: UInt = 10,
|
||||
date: String? = nil) -> String? {
|
||||
|
||||
let ver = "1"
|
||||
|
|
@ -25,15 +56,13 @@ public struct Hashcash {
|
|||
ts = date
|
||||
} else {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = stampSeconds ? "yyMMddHHmmss" : "yyMMdd"
|
||||
formatter.dateFormat = "yyMMddHHmmss"
|
||||
ts = formatter.string(from: Date())
|
||||
}
|
||||
|
||||
let challenge = "\(ver):\(bits):\(ts):\(resource):"
|
||||
|
||||
var counter = 0
|
||||
let hexDigits = Int(ceil((Double(bits) / 4)))
|
||||
let zeros = String(repeating: "0", count: hexDigits)
|
||||
|
||||
while true {
|
||||
guard let digest = ("\(challenge):\(counter)").sha1 else {
|
||||
|
|
@ -41,87 +70,27 @@ public struct Hashcash {
|
|||
return nil
|
||||
}
|
||||
|
||||
if digest.prefix(hexDigits) == zeros {
|
||||
if digest == bits {
|
||||
return "\(challenge):\(counter)"
|
||||
}
|
||||
counter += 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Checks whether a stamp is valid
|
||||
- parameter stamp: stamp to check e.g. 1:16:040922:foo::+ArSrtKd:164b3
|
||||
- parameter resource: resource to check against
|
||||
- parameter bits: minimum bit value to check
|
||||
- parameter expiration: number of seconds old the stamp may be
|
||||
- returns: true if stamp is valid
|
||||
*/
|
||||
public func check(stamp: String,
|
||||
resource: String? = nil,
|
||||
bits: UInt,
|
||||
expiration: UInt? = nil) -> Bool {
|
||||
|
||||
guard let stamped = Stamp(stamp: stamp) else {
|
||||
print("Invalid stamp format")
|
||||
return false
|
||||
}
|
||||
|
||||
if let res = resource, res != stamped.resource {
|
||||
print("Resources do not match")
|
||||
return false
|
||||
}
|
||||
|
||||
var count = bits
|
||||
if let claim = stamped.claim {
|
||||
if bits > claim {
|
||||
return false
|
||||
} else {
|
||||
count = claim
|
||||
}
|
||||
}
|
||||
|
||||
if let expiration = expiration {
|
||||
let goodUntilDate = Date(timeIntervalSinceNow: -TimeInterval(expiration))
|
||||
if (stamped.date < goodUntilDate) {
|
||||
print("Stamp expired")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
guard let digest = stamp.sha1 else {
|
||||
return false
|
||||
}
|
||||
|
||||
let hexDigits = Int(ceil((Double(count) / 4)))
|
||||
return digest.hasPrefix(String(repeating: "0", count: hexDigits))
|
||||
}
|
||||
|
||||
/**
|
||||
Generates random string of chosen length
|
||||
- parameter length: length of random string
|
||||
- returns: random string
|
||||
*/
|
||||
internal func salt(length: UInt) -> String {
|
||||
let allowedCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+/="
|
||||
var result = ""
|
||||
|
||||
for _ in 0..<length {
|
||||
let randomValue = arc4random_uniform(UInt32(allowedCharacters.count))
|
||||
result += "\(allowedCharacters[allowedCharacters.index(allowedCharacters.startIndex, offsetBy: Int(randomValue))])"
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var sha1: 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 hexBytes = digest.map { String(format: "%02x", $0) }
|
||||
return hexBytes.joined()
|
||||
let bigEndianValue = digest.withUnsafeBufferPointer {
|
||||
($0.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: 1) { $0 })
|
||||
}.pointee
|
||||
let value = UInt32(bigEndian: bigEndianValue)
|
||||
return value.leadingZeroBitCount
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
//
|
||||
// Stamp.swift
|
||||
//
|
||||
//
|
||||
// Created by Matt Kiazyk on 2023-02-23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct Stamp {
|
||||
|
||||
private static let DateFormatWithoutTime = "yyMMdd"
|
||||
private static let DateFormatWithTime = "yyMMddHHmmss"
|
||||
|
||||
public let version : UInt
|
||||
public let date : Date
|
||||
public let resource : String
|
||||
|
||||
// Version 1 only
|
||||
public var claim : UInt?
|
||||
public var counter : String?
|
||||
public var ext : String?
|
||||
public var random : String?
|
||||
|
||||
// Version 0 only
|
||||
public var suffix : String?
|
||||
|
||||
init?(stamp: String) {
|
||||
let components = stamp.components(separatedBy: ":")
|
||||
|
||||
if (components.count < 1) {
|
||||
print("No stamp components. Ensure it is separated by a `:`")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let version = UInt(components[0]) else {
|
||||
print("Unable to parse stamp version")
|
||||
return nil
|
||||
}
|
||||
|
||||
self.version = version
|
||||
|
||||
if self.version > 1 {
|
||||
print("Version > 1. Not handled")
|
||||
return nil
|
||||
}
|
||||
|
||||
if (self.version == 0 && components.count < 4) {
|
||||
print("Not enough components for version 0")
|
||||
return nil
|
||||
}
|
||||
|
||||
if (self.version == 1 && components.count < 7) {
|
||||
print("Not enough components for version 1")
|
||||
return nil
|
||||
}
|
||||
|
||||
if (self.version == 0) {
|
||||
if let date = Stamp.parseDate(dateString: components[1]) {
|
||||
self.date = date
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
self.resource = components[2]
|
||||
self.suffix = components[3]
|
||||
} else if (self.version == 1) {
|
||||
if let claim = UInt(components[1]) {
|
||||
self.claim = claim
|
||||
}
|
||||
if let date = Stamp.parseDate(dateString: components[2]) {
|
||||
self.date = date
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
self.resource = components[3]
|
||||
self.ext = components[4]
|
||||
self.random = components[5]
|
||||
self.counter = components[6]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseDate(dateString: String) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = Stamp.DateFormatWithoutTime
|
||||
|
||||
if let date = formatter.date(from: dateString) {
|
||||
return date
|
||||
}
|
||||
|
||||
formatter.dateFormat = Stamp.DateFormatWithTime
|
||||
|
||||
if let date = formatter.date(from: dateString) {
|
||||
return date
|
||||
} else {
|
||||
print("Unable to parse date")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ public extension URL {
|
|||
static let requestSecurityCode = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/phone")!
|
||||
static func submitSecurityCode(_ code: SecurityCode) -> URL { URL(string: "https://idmsa.apple.com/appleauth/auth/verify/\(code.urlPathComponent)/securitycode")! }
|
||||
static let trust = URL(string: "https://idmsa.apple.com/appleauth/auth/2sv/trust")!
|
||||
static let federate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate")!
|
||||
static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")!
|
||||
}
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ public 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
|
||||
|
|
@ -27,6 +28,7 @@ public extension URLRequest {
|
|||
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
|
||||
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
|
||||
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
|
||||
request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash
|
||||
request.allHTTPHeaderFields?["Accept"] = "application/json, text/javascript"
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName, password: password))
|
||||
|
|
@ -117,4 +119,21 @@ public extension URLRequest {
|
|||
static var olympusSession: URLRequest {
|
||||
return URLRequest(url: .olympusSession)
|
||||
}
|
||||
|
||||
static func federate(account: String, serviceKey: String) throws -> URLRequest {
|
||||
struct FederateRequest: Encodable {
|
||||
let accountName: String
|
||||
let rememberMe: Bool
|
||||
}
|
||||
var request = URLRequest(url: .signIn)
|
||||
request.allHTTPHeaderFields?["Accept"] = "application/json"
|
||||
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
|
||||
request.httpMethod = "GET"
|
||||
|
||||
// let encoder = JSONEncoder()
|
||||
// encoder.outputFormatting = .withoutEscapingSlashes
|
||||
// request.httpBody = try encoder.encode(FederateRequest(accountName: account, rememberMe: true))
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,21 +4,24 @@ import XCTest
|
|||
final class AppleAPITests: XCTestCase {
|
||||
|
||||
func testValidHashCashMint() {
|
||||
let bits: UInt = 11
|
||||
let resource = "4d74fb15eb23f465f1f6fcbf534e5877"
|
||||
let testDate = "20230223170600"
|
||||
|
||||
let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate)
|
||||
XCTAssertEqual(stamp, "1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373")
|
||||
}
|
||||
func testValidHashCashMint2() {
|
||||
let bits: UInt = 10
|
||||
let resource = "bb63edf88d2f9c39f23eb4d6f0281158"
|
||||
let testDate = "20230224001754"
|
||||
|
||||
// "1:11:20230224004345:8982e236688f6ebf588c4bd4b445c4cc::877"
|
||||
// 7395f792caf430dca2d07ae7be0c63fa
|
||||
|
||||
|
||||
let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate)
|
||||
XCTAssertNotNil(stamp)
|
||||
XCTAssertEqual(stamp, "1:10:20230224001754:bb63edf88d2f9c39f23eb4d6f0281158::866")
|
||||
|
||||
print(stamp)
|
||||
}
|
||||
|
||||
static var allTests = [
|
||||
("testValidHashCashMint", testValidHashCashMint),
|
||||
("testValidHashCashMint2", testValidHashCashMint2),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue