diff --git a/Package.resolved b/Package.resolved index 3e0b304..9ebc683 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "eb3e5187e876f9040ec299011c346485d151ff02fb30e8b0987fcfb40eb57ce9", + "originHash" : "da7dbc47daab7063084554872b7130920da9bb33f6fcf639bac73424ff3676c6", "pins" : [ { "identity" : "bigint", @@ -108,6 +108,15 @@ "revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1", "version" : "1.7.2" } + }, + { + "identity" : "swift-zip-archive", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rorkai/swift-zip-archive.git", + "state" : { + "revision" : "a08621b42e9122208399665b68cdf32ed190bec6", + "version" : "0.8.1-rork.3" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index c858dc8..f9d62a5 100644 --- a/Package.swift +++ b/Package.swift @@ -2,14 +2,13 @@ import PackageDescription -let nativePlatforms: [Platform] = [ +let hostPlatforms: [Platform] = [ .macOS, .macCatalyst, .iOS, .tvOS, .watchOS, .visionOS, - .driverKit, .linux, .windows, .android, @@ -91,6 +90,10 @@ let bigInt: Package.Dependency = .package( url: "https://github.com/attaswift/BigInt.git", .upToNextMajor(from: "5.7.0") ) +let swiftZipArchive: Package.Dependency = .package( + url: "https://github.com/rorkai/swift-zip-archive.git", + exact: "0.8.1-rork.3" +) var dependencies: [Package.Dependency] = [ swiftArgumentParser, @@ -99,6 +102,7 @@ var dependencies: [Package.Dependency] = [ swiftCertificates, swiftCrypto, bigInt, + swiftZipArchive, ] var targets: [Target] = [ @@ -142,12 +146,12 @@ var targets: [Target] = [ dependencies: [ .target( name: "RorkDeviceLwIP", - condition: .when(platforms: nativePlatforms) + condition: .when(platforms: hostPlatforms) ), .product( name: "BigInt", package: "BigInt", - condition: .when(platforms: nativePlatforms) + condition: .when(platforms: hostPlatforms) ), .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOEmbedded", package: "swift-nio"), @@ -158,18 +162,15 @@ var targets: [Target] = [ .product( name: "X509", package: "swift-certificates", - condition: .when(platforms: nativePlatforms) + condition: .when(platforms: hostPlatforms) ), .product( name: "CryptoExtras", package: "swift-crypto", - condition: .when(platforms: nativePlatforms) - ), - .product( - name: "Crypto", - package: "swift-crypto", - condition: .when(platforms: [.wasi]) + condition: .when(platforms: hostPlatforms) ), + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "ZipArchive", package: "swift-zip-archive"), ] ), .executableTarget( @@ -190,6 +191,7 @@ var targets: [Target] = [ .product(name: "NIOSSL", package: "swift-nio-ssl"), .product(name: "X509", package: "swift-certificates"), .product(name: "CryptoExtras", package: "swift-crypto"), + .product(name: "ZipArchive", package: "swift-zip-archive"), ], resources: [ .process("Fixtures"), diff --git a/Sources/RorkDevice/DeveloperDiskImage/AppleTSSClient.swift b/Sources/RorkDevice/DeveloperDiskImage/AppleTSSClient.swift new file mode 100644 index 0000000..22dc3c5 --- /dev/null +++ b/Sources/RorkDevice/DeveloperDiskImage/AppleTSSClient.swift @@ -0,0 +1,426 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// HTTP response shape used to test TSS behavior without network access. +struct TSSHTTPResponse: Sendable { + /// HTTP status returned by Apple's signing endpoint. + let statusCode: Int + + /// Raw key-value-prefixed TSS response body. + let body: Data +} + +/// Platform-neutral request passed to the Apple TSS HTTP boundary. +struct TSSHTTPRequest: Sendable { + /// HTTPS endpoint that receives the signing request. + let url: URL + + /// HTTP method used for the request. + let method: String + + /// Request headers required by Apple TSS. + let headers: [String: String] + + /// XML property-list request body. + let body: Data +} + +/// Minimal HTTP boundary for Apple's TSS request. +protocol TSSHTTPTransport: Sendable { + /// Sends one prepared TSS request through an authenticated HTTP transport. + func response(for request: TSSHTTPRequest) async throws -> TSSHTTPResponse +} + +/// Ticket boundary used by the image-mounting workflow. +protocol DeveloperDiskImageTicketRequesting: Sendable { + /// Requests the IMG4 ticket for one device, nonce, and build identity. + func ticket( + for buildIdentity: DeveloperDiskImageBuildIdentity, + identifiers: PersonalizationIdentifiers, + nonce: Data, + ecid: UInt64 + ) async throws -> Data +} + +/// URLSession transport that retains the platform's default certificate checks. +#if canImport(FoundationNetworking) || canImport(Darwin) +private struct URLSessionTSSHTTPTransport: TSSHTTPTransport { + /// Sends a request through a short-lived, HTTPS-only ephemeral session. + func response(for request: TSSHTTPRequest) async throws -> TSSHTTPResponse { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = request.method + urlRequest.httpBody = request.body + for (name, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 60 + configuration.timeoutIntervalForResource = 60 + let session = URLSession( + configuration: configuration, + delegate: HTTPSOnlyURLSessionDelegate(), + delegateQueue: nil + ) + defer { + session.finishTasksAndInvalidate() + } + let (body, response) = try await session.data(for: urlRequest) + guard let response = response as? HTTPURLResponse else { + throw RorkDeviceError.transport( + "Apple TSS did not return an HTTP response." + ) + } + return TSSHTTPResponse( + statusCode: response.statusCode, + body: body + ) + } +} +#else +/// Reports that this platform has no authenticated HTTP client implementation. +private struct UnavailableTSSHTTPTransport: TSSHTTPTransport { + func response(for _: TSSHTTPRequest) async throws -> TSSHTTPResponse { + throw RorkDeviceError.transport( + "Apple TSS HTTP transport is unavailable on this platform." + ) + } +} +#endif + +/// Builds the property list accepted by Apple's TSS endpoint. +struct AppleTSSRequest { + /// Request body ready for XML property-list serialization. + let propertyList: [String: Any] + + /// Builds a production IMG4 ticket request for one device and image. + /// + /// - Parameters: + /// - buildIdentity: Matching DDI identity and trusted manifest entries. + /// - identifiers: Hardware values reported by the connected device. + /// - nonce: Fresh personalization nonce reported by the device. + /// - ecid: Device ECID reported by Lockdown. + /// - requestIdentifier: Unique request identifier sent to Apple. + /// - Throws: An input error when a trusted manifest entry is incomplete. + init( + buildIdentity: DeveloperDiskImageBuildIdentity, + identifiers: PersonalizationIdentifiers, + nonce: Data, + ecid: UInt64, + requestIdentifier: UUID = UUID() + ) throws { + let parameters: [String: Any] = [ + "ApProductionMode": true, + "ApSecurityMode": true, + "ApSupportsImg4": true, + ] + var propertyList: [String: Any] = [ + "@ApImg4Ticket": true, + "@BBTicket": true, + "@HostPlatformInfo": "mac", + "@VersionInfo": "libauthinstall-1104.0.9", + "@UUID": requestIdentifier.uuidString.uppercased(), + "ApBoardID": identifiers.boardID, + "ApChipID": identifiers.chipID, + "ApECID": ecid, + "ApNonce": nonce, + "ApProductionMode": true, + "ApSecurityDomain": identifiers.securityDomain, + "ApSecurityMode": true, + "SepNonce": Data(repeating: 0, count: 20), + "UID_MODE": false, + ] + + for (key, value) in buildIdentity.propertyList + where key == "UniqueBuildID" || key.hasPrefix("Ap,") { + propertyList[key] = value + } + for (key, value) in identifiers.additionalTSSParameters + where key.hasPrefix("Ap,") { + propertyList[key] = value + } + for (key, value) in buildIdentity.manifestEntries { + guard let entry = value as? [String: Any], + plistBoolean(entry["Trusted"]) == true, + entry["Info"] is [String: Any] + else { + continue + } + propertyList[key] = try ticketEntry( + from: entry, + parameters: parameters + ) + } + self.propertyList = propertyList + } +} + +/// Parsed response from Apple's key-value-prefixed TSS protocol. +struct AppleTSSResponse { + /// IMG4 ticket returned by Apple. + let ticket: Data + + /// Parses and validates a bounded Apple TSS response. + /// + /// - Parameter data: Raw HTTP response body. + /// - Throws: A protocol violation when the response is malformed, rejected, + /// oversized, or missing `ApImg4Ticket`. + init(data: Data) throws { + guard data.count <= 16 * 1024 * 1024 else { + throw RorkDeviceError.protocolViolation( + "Apple TSS response exceeded the 16 MiB limit." + ) + } + guard let response = String(data: data, encoding: .utf8) else { + throw RorkDeviceError.protocolViolation( + "Apple TSS response was not UTF-8." + ) + } + let status = try field( + named: "STATUS", + in: response + ) + guard let statusCode = Int(status) else { + throw RorkDeviceError.protocolViolation( + "Apple TSS response contained an invalid status." + ) + } + let message = try field( + named: "MESSAGE", + in: response + ) + guard statusCode == 0, message == "SUCCESS" else { + throw RorkDeviceError.protocolViolation( + "Apple TSS rejected the request with status \(statusCode): \(message)" + ) + } + let requestString = try trailingField( + named: "REQUEST_STRING", + in: response + ) + let decoded: Any + do { + decoded = try PropertyListCodec.decode(Data(requestString.utf8)) + } catch { + guard let percentDecoded = requestString.removingPercentEncoding, + percentDecoded != requestString + else { + throw RorkDeviceError.protocolViolation( + "Apple TSS response contained an invalid ticket property list." + ) + } + do { + decoded = try PropertyListCodec.decode( + Data(percentDecoded.utf8) + ) + } catch { + throw RorkDeviceError.protocolViolation( + "Apple TSS response contained an invalid ticket property list." + ) + } + } + guard let propertyList = decoded as? [String: Any], + let ticket = propertyList["ApImg4Ticket"] as? Data, + !ticket.isEmpty + else { + throw RorkDeviceError.protocolViolation( + "Apple TSS response did not contain ApImg4Ticket." + ) + } + self.ticket = ticket + } +} + +/// Requests personalized Developer Disk Image tickets from Apple. +struct AppleTSSClient: DeveloperDiskImageTicketRequesting { + /// Apple's production signing endpoint. + private static let endpoint = URL( + string: "https://gs.apple.com/TSS/controller?action=2" + )! + + /// HTTP transport retained for dependency injection and strict TLS tests. + private let transport: any TSSHTTPTransport + + /// Creates a ticket client using the current platform's HTTP transport. + init() { + #if canImport(FoundationNetworking) || canImport(Darwin) + transport = URLSessionTSSHTTPTransport() + #else + transport = UnavailableTSSHTTPTransport() + #endif + } + + /// Creates a ticket client with an injected HTTP transport. + init(transport: any TSSHTTPTransport) { + self.transport = transport + } + + /// Requests an IMG4 ticket for the selected Developer Disk Image. + /// + /// - Returns: Nonempty `ApImg4Ticket` bytes accepted by image mounter. + /// - Throws: An input or protocol error while building/parsing the request, + /// or a transport error when Apple TSS cannot be reached successfully. + func ticket( + for buildIdentity: DeveloperDiskImageBuildIdentity, + identifiers: PersonalizationIdentifiers, + nonce: Data, + ecid: UInt64 + ) async throws -> Data { + let propertyList = try AppleTSSRequest( + buildIdentity: buildIdentity, + identifiers: identifiers, + nonce: nonce, + ecid: ecid + ).propertyList + let body = try PropertyListCodec.encode(propertyList) + let request = TSSHTTPRequest( + url: Self.endpoint, + method: "POST", + headers: [ + "Cache-Control": "no-cache", + "Content-Type": "text/xml; charset=utf-8", + "User-Agent": "InetURL/1.0", + ], + body: body + ) + + let response: TSSHTTPResponse + do { + response = try await transport.response(for: request) + } catch let error as RorkDeviceError { + throw error + } catch { + throw RorkDeviceError.transport( + "Apple TSS request failed: \(error.localizedDescription)" + ) + } + guard response.statusCode == 200 else { + throw RorkDeviceError.transport( + "Apple TSS returned HTTP status \(response.statusCode)." + ) + } + return try AppleTSSResponse(data: response.body).ticket + } +} + +/// Removes manifest-only metadata and applies production/security rules. +private func ticketEntry( + from manifestEntry: [String: Any], + parameters: [String: Any] +) throws -> [String: Any] { + var ticketEntry = manifestEntry + guard let info = ticketEntry.removeValue(forKey: "Info") + as? [String: Any] + else { + throw RorkDeviceError.invalidInput( + "Developer Disk Image manifest entry is missing Info." + ) + } + if ticketEntry["Digest"] == nil { + ticketEntry["Digest"] = Data() + } + if let rules = info["RestoreRequestRules"] as? [[String: Any]], + !rules.isEmpty + { + for rule in rules { + guard let conditions = rule["Conditions"] + as? [String: Any], + conditions.allSatisfy({ condition in + guard let actualValue = restoreRuleValue( + for: condition.key, + parameters: parameters + ) else { + return false + } + return propertyListValuesEqual( + actualValue, + condition.value + ) + }), + let actions = rule["Actions"] as? [String: Any] + else { + continue + } + for (key, value) in actions { + if let number = value as? NSNumber, + number.intValue == 255 + { + continue + } + ticketEntry[key] = value + } + } + } else { + ticketEntry["EPRO"] = true + ticketEntry["ESEC"] = true + } + return ticketEntry +} + +/// Maps Apple restore-rule condition names to the active TSS parameters. +private func restoreRuleValue( + for condition: String, + parameters: [String: Any] +) -> Any? { + switch condition { + case "ApRawProductionMode", "ApCurrentProductionMode": + return parameters["ApProductionMode"] + case "ApRawSecurityMode": + return parameters["ApSecurityMode"] + case "ApRequiresImage4": + return parameters["ApSupportsImg4"] + case "ApDemotionPolicyOverride": + return parameters["DemotionPolicy"] + case "ApInRomDFU": + return parameters["ApInRomDFU"] + default: + return nil + } +} + +/// Compares Foundation property-list scalars without coercing their types. +private func propertyListValuesEqual(_ lhs: Any, _ rhs: Any) -> Bool { + guard let lhs = lhs as? NSObject, + let rhs = rhs as? NSObject + else { + return false + } + return lhs.isEqual(rhs) +} + +/// Extracts a key-value field that ends at the next ampersand. +private func field(named name: String, in response: String) throws -> String { + let prefix = "\(name)=" + guard let start = response.range(of: prefix)?.upperBound else { + throw RorkDeviceError.protocolViolation( + "Apple TSS response was missing \(name)." + ) + } + let suffix = response[start...] + let end = suffix.firstIndex(of: "&") ?? response.endIndex + return String(response[start.. String { + let prefix = "\(name)=" + guard let start = response.range(of: prefix)?.upperBound else { + throw RorkDeviceError.protocolViolation( + "Apple TSS response was missing \(name)." + ) + } + return String(response[start...]) +} + +/// Reads a plist Boolean while preserving `false`. +private func plistBoolean(_ value: Any?) -> Bool? { + if let value = value as? Bool { + return value + } + return (value as? NSNumber)?.boolValue +} diff --git a/Sources/RorkDevice/DeveloperDiskImage/DeveloperDiskImageStore.swift b/Sources/RorkDevice/DeveloperDiskImage/DeveloperDiskImageStore.swift new file mode 100644 index 0000000..2881e85 --- /dev/null +++ b/Sources/RorkDevice/DeveloperDiskImage/DeveloperDiskImageStore.swift @@ -0,0 +1,594 @@ +import Crypto +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +import ZipArchive + +/// Remote archive containing an iOS 17+ personalized DDI Restore directory. +public struct DeveloperDiskImageSource: Equatable, Sendable { + /// HTTPS location of the ZIP archive. + public let archiveURL: URL + + /// Lowercase SHA-256 used to authenticate the downloaded archive. + public let expectedSHA256: String + + /// Creates an authenticated archive source. + /// + /// The library intentionally does not select or endorse a hosting provider. + /// Applications must choose their source and pin the exact archive digest. + /// + /// - Parameters: + /// - archiveURL: HTTPS URL for the ZIP archive. + /// - expectedSHA256: Expected archive SHA-256 in hexadecimal form. The + /// stored value is normalized to lowercase. + /// - Throws: `RorkDeviceError.invalidInput` when the URL is not HTTPS or + /// the digest is not exactly 64 hexadecimal characters. + public init(archiveURL: URL, expectedSHA256: String) throws { + guard archiveURL.scheme?.lowercased() == "https", + archiveURL.host != nil + else { + throw RorkDeviceError.invalidInput( + "Developer Disk Image archive URL must use HTTPS." + ) + } + let normalizedDigest = expectedSHA256.lowercased() + let hexadecimalBytes = normalizedDigest.utf8 + guard hexadecimalBytes.count == SHA256.Digest.byteCount * 2, + hexadecimalBytes.allSatisfy({ + (UInt8(ascii: "0") ... UInt8(ascii: "9")).contains($0) + || (UInt8(ascii: "a") ... UInt8(ascii: "f")) + .contains($0) + }) + else { + throw RorkDeviceError.invalidInput( + "Developer Disk Image archive SHA-256 must contain 64 hexadecimal characters." + ) + } + self.archiveURL = archiveURL + self.expectedSHA256 = normalizedDigest + } +} + +/// HTTP metadata returned after an archive download. +struct DeveloperDiskImageArchiveHTTPResponse: Sendable { + /// Final HTTP status returned by the archive host. + let statusCode: Int + + /// Declared response length, or `nil` when the server omitted it. + let expectedContentLength: Int64? +} + +/// Download boundary used to keep archive storage tests offline. +protocol DeveloperDiskImageArchiveDownloading: Sendable { + /// Downloads an archive while enforcing the caller's byte budget. + func download( + from sourceURL: URL, + to destinationURL: URL, + maximumByteCount: UInt64 + ) async throws -> DeveloperDiskImageArchiveHTTPResponse +} + +/// Resource bounds applied before extracting an untrusted archive. +struct DeveloperDiskImageArchiveLimits: Sendable { + /// Production limits for archive size, entry count, and expansion. + static let standard = DeveloperDiskImageArchiveLimits( + maximumArchiveSize: 512 * 1024 * 1024, + maximumEntryCount: 10_000, + maximumExpandedSize: 2 * 1024 * 1024 * 1024 + ) + + /// Maximum compressed archive size accepted on disk or over the network. + let maximumArchiveSize: UInt64 + + /// Maximum number of ZIP entries inspected before extraction. + let maximumEntryCount: Int + + /// Maximum total uncompressed size across all ZIP entries. + let maximumExpandedSize: UInt64 +} + +/// Downloads, authenticates, and caches personalized DDI archives. +public struct DeveloperDiskImageStore: Sendable { + private let cacheDirectory: URL + private let downloader: any DeveloperDiskImageArchiveDownloading + private let limits: DeveloperDiskImageArchiveLimits + + /// Default cache used by the command-line client and host applications. + public static let defaultCacheDirectory: URL = { + let root = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + return root.appendingPathComponent( + "rork-device/DeveloperDiskImages", + isDirectory: true + ) + }() + + /// Creates a DDI archive store. + /// + /// - Parameter cacheDirectory: Directory for digest-keyed extracted images. + public init( + cacheDirectory: URL = DeveloperDiskImageStore.defaultCacheDirectory + ) { + let downloader: any DeveloperDiskImageArchiveDownloading + #if canImport(FoundationNetworking) || canImport(Darwin) + downloader = URLSessionDeveloperDiskImageArchiveDownloader() + #else + downloader = UnavailableDeveloperDiskImageArchiveDownloader() + #endif + self.init( + cacheDirectory: cacheDirectory, + downloader: downloader, + limits: .standard + ) + } + + /// Creates a store with injectable I/O and resource limits for tests. + init( + cacheDirectory: URL, + downloader: any DeveloperDiskImageArchiveDownloading, + limits: DeveloperDiskImageArchiveLimits = .standard + ) { + self.cacheDirectory = cacheDirectory.standardizedFileURL + self.downloader = downloader + self.limits = limits + } + + /// Returns a verified, extracted `Restore` directory for `source`. + /// + /// Downloads are keyed by the pinned SHA-256. A completed cache entry is + /// reused without contacting the archive host again. + /// + /// - Parameter source: HTTPS archive location and expected digest. + /// - Returns: Local directory containing `BuildManifest.plist`. + /// - Throws: An input error when the archive, digest, ZIP layout, or cache + /// contents are invalid; a transport error when the download fails. + public func prepareRestoreDirectory( + from source: DeveloperDiskImageSource + ) async throws -> URL { + let finalDirectory = cacheDirectory.appendingPathComponent( + source.expectedSHA256, + isDirectory: true + ) + let finalRestoreDirectory = finalDirectory.appendingPathComponent( + "Restore", + isDirectory: true + ) + if isCompleteRestoreDirectory(finalRestoreDirectory) { + return finalRestoreDirectory + } + + do { + try FileManager.default.createDirectory( + at: cacheDirectory, + withIntermediateDirectories: true + ) + } catch { + throw RorkDeviceError.invalidInput( + "Could not create Developer Disk Image cache: \(error.localizedDescription)" + ) + } + + let operationDirectory = cacheDirectory.appendingPathComponent( + ".\(source.expectedSHA256)-\(UUID().uuidString)", + isDirectory: true + ) + defer { + try? FileManager.default.removeItem(at: operationDirectory) + } + do { + try FileManager.default.createDirectory( + at: operationDirectory, + withIntermediateDirectories: true + ) + } catch { + throw RorkDeviceError.invalidInput( + "Could not prepare Developer Disk Image cache: \(error.localizedDescription)" + ) + } + + let archiveURL = operationDirectory.appendingPathComponent( + "archive.zip" + ) + let response: DeveloperDiskImageArchiveHTTPResponse + do { + response = try await downloader.download( + from: source.archiveURL, + to: archiveURL, + maximumByteCount: limits.maximumArchiveSize + ) + } catch let error as RorkDeviceError { + throw error + } catch { + throw RorkDeviceError.transport( + "Developer Disk Image archive download failed: \(error.localizedDescription)" + ) + } + guard response.statusCode == 200 else { + throw RorkDeviceError.transport( + "Developer Disk Image archive returned HTTP status \(response.statusCode)." + ) + } + + let archiveSize = try fileSize(at: archiveURL) + guard archiveSize <= limits.maximumArchiveSize else { + throw RorkDeviceError.invalidInput( + "Developer Disk Image archive exceeds the \(limits.maximumArchiveSize)-byte limit." + ) + } + if let expectedLength = response.expectedContentLength, + expectedLength >= 0, + UInt64(expectedLength) != archiveSize + { + throw RorkDeviceError.transport( + "Developer Disk Image archive length did not match the HTTP response." + ) + } + guard try sha256HexDigest(of: archiveURL) + == source.expectedSHA256 + else { + throw RorkDeviceError.invalidInput( + "Developer Disk Image archive SHA-256 did not match the expected digest." + ) + } + + let extractedDirectory = operationDirectory.appendingPathComponent( + "extracted", + isDirectory: true + ) + do { + try FileManager.default.createDirectory( + at: extractedDirectory, + withIntermediateDirectories: true + ) + } catch { + throw RorkDeviceError.invalidInput( + "Could not prepare Developer Disk Image extraction: \(error.localizedDescription)" + ) + } + try extractArchive( + at: archiveURL, + to: extractedDirectory, + limits: limits + ) + + let extractedRestoreDirectory = try restoreDirectory( + in: extractedDirectory + ) + let preparedDirectory = operationDirectory.appendingPathComponent( + "prepared", + isDirectory: true + ) + do { + try FileManager.default.createDirectory( + at: preparedDirectory, + withIntermediateDirectories: true + ) + try FileManager.default.moveItem( + at: extractedRestoreDirectory, + to: preparedDirectory.appendingPathComponent( + "Restore", + isDirectory: true + ) + ) + if isCompleteRestoreDirectory(finalRestoreDirectory) { + return finalRestoreDirectory + } + if FileManager.default.fileExists(atPath: finalDirectory.path) { + try FileManager.default.removeItem(at: finalDirectory) + } + try FileManager.default.moveItem( + at: preparedDirectory, + to: finalDirectory + ) + } catch { + if isCompleteRestoreDirectory(finalRestoreDirectory) { + return finalRestoreDirectory + } + throw RorkDeviceError.invalidInput( + "Could not cache Developer Disk Image: \(error.localizedDescription)" + ) + } + return finalRestoreDirectory + } +} + +/// URLSession downloader that preserves default certificate validation. +#if canImport(FoundationNetworking) || canImport(Darwin) +private struct URLSessionDeveloperDiskImageArchiveDownloader: + DeveloperDiskImageArchiveDownloading +{ + /// Downloads one archive to a caller-owned temporary destination. + func download( + from sourceURL: URL, + to destinationURL: URL, + maximumByteCount: UInt64 + ) async throws -> DeveloperDiskImageArchiveHTTPResponse { + let delegate = DeveloperDiskImageDownloadDelegate( + maximumByteCount: maximumByteCount + ) + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 60 + configuration.timeoutIntervalForResource = 10 * 60 + let session = URLSession( + configuration: configuration, + delegate: delegate, + delegateQueue: nil + ) + defer { + session.finishTasksAndInvalidate() + } + + let temporaryURL: URL + let response: URLResponse + do { + (temporaryURL, response) = try await session.download( + for: URLRequest(url: sourceURL) + ) + } catch { + if delegate.didExceedLimit { + throw RorkDeviceError.invalidInput( + "Developer Disk Image archive exceeds the \(maximumByteCount)-byte limit." + ) + } + throw error + } + guard let httpResponse = response as? HTTPURLResponse else { + throw RorkDeviceError.transport( + "Developer Disk Image archive host did not return an HTTP response." + ) + } + try FileManager.default.copyItem( + at: temporaryURL, + to: destinationURL + ) + let expectedLength = httpResponse.expectedContentLength + return DeveloperDiskImageArchiveHTTPResponse( + statusCode: httpResponse.statusCode, + expectedContentLength: expectedLength >= 0 + ? expectedLength + : nil + ) + } +} + +/// Enforces archive size and HTTPS redirect policy during a download. +private final class DeveloperDiskImageDownloadDelegate: + HTTPSOnlyURLSessionDelegate, + URLSessionDownloadDelegate, + @unchecked Sendable +{ + /// Maximum number of compressed bytes accepted from the response body. + private let maximumByteCount: UInt64 + + /// Serializes byte-limit state across URLSession delegate callbacks. + private let lock = NSLock() + + /// Records whether cancellation was caused by the configured byte limit. + private var exceededLimit = false + + /// Creates a delegate with one compressed-response byte budget. + init(maximumByteCount: UInt64) { + self.maximumByteCount = maximumByteCount + } + + /// Whether this delegate cancelled a download after exceeding its limit. + var didExceedLimit: Bool { + lock.withLock { exceededLimit } + } + + /// Cancels a chunked response as soon as its compressed bytes exceed the + /// configured archive limit. + func urlSession( + _: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData _: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite _: Int64 + ) { + guard totalBytesWritten >= 0, + UInt64(totalBytesWritten) > maximumByteCount + else { + return + } + lock.withLock { + exceededLimit = true + } + downloadTask.cancel() + } + + /// Leaves file ownership to URLSession's async download API. + func urlSession( + _: URLSession, + downloadTask _: URLSessionDownloadTask, + didFinishDownloadingTo _: URL + ) {} +} +#else +/// Reports that this platform has no authenticated HTTP client implementation. +private struct UnavailableDeveloperDiskImageArchiveDownloader: + DeveloperDiskImageArchiveDownloading +{ + func download( + from _: URL, + to _: URL, + maximumByteCount _: UInt64 + ) async throws -> DeveloperDiskImageArchiveHTTPResponse { + throw RorkDeviceError.transport( + "Developer Disk Image archive downloads are unavailable on this platform." + ) + } +} +#endif + +/// Returns a lowercase streaming SHA-256 for a file. +func sha256HexDigest(of fileURL: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: fileURL) + defer { + try? handle.close() + } + var hasher = SHA256() + while let chunk = try handle.read(upToCount: 1024 * 1024), + !chunk.isEmpty + { + hasher.update(data: chunk) + } + return hasher.finalize().map { + String(format: "%02x", $0) + }.joined() +} + +/// Returns the nonzero size of a regular, non-symbolic-link archive. +private func fileSize(at fileURL: URL) throws -> UInt64 { + let values = try fileURL.resourceValues( + forKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey] + ) + guard values.isRegularFile == true, + values.isSymbolicLink != true, + let size = values.fileSize, + size > 0 + else { + throw RorkDeviceError.invalidInput( + "Developer Disk Image archive is missing or empty." + ) + } + return UInt64(size) +} + +/// Extracts one authenticated archive using DDI-specific safety policies. +private func extractArchive( + at archiveURL: URL, + to extractedDirectory: URL, + limits: DeveloperDiskImageArchiveLimits +) throws { + let options = ZipArchiveExtractionOptions( + symbolicLinkPolicy: .reject, + limits: .init( + maximumEntryCount: limits.maximumEntryCount, + maximumTotalUncompressedSize: Int64( + clamping: limits.maximumExpandedSize + ) + ) + ) + do { + try ZipArchiveReader.withFile(archiveURL.path) { reader in + try reader.extract( + to: .init(extractedDirectory.path), + options: options + ) + } + } catch let error as ZipArchiveReaderError { + throw developerDiskImageArchiveError( + for: error, + limits: limits + ) + } catch { + throw RorkDeviceError.invalidInput( + "Could not extract Developer Disk Image archive: \(error.localizedDescription)" + ) + } +} + +/// Maps backend-specific safety failures to store-level diagnostics. +private func developerDiskImageArchiveError( + for error: ZipArchiveReaderError, + limits: DeveloperDiskImageArchiveLimits +) -> RorkDeviceError { + if error == .entryCountLimitExceeded { + return .invalidInput( + "Developer Disk Image archive contains more than \(limits.maximumEntryCount) entries." + ) + } + if error == .totalUncompressedSizeLimitExceeded { + return .invalidInput( + "Developer Disk Image archive expands beyond the \(limits.maximumExpandedSize)-byte limit." + ) + } + if error == .symbolicLinkNotAllowed { + return .invalidInput( + "Developer Disk Image archive contains symbolic links." + ) + } + if error == .unsafeExtractionPath + || error == .duplicateExtractionPath + || error == .conflictingExtractionPath + || error == .unsafeDestinationPath + { + return .invalidInput( + "Developer Disk Image archive contains an unsafe or duplicate path." + ) + } + return .invalidInput( + "Could not extract Developer Disk Image archive: \(error.localizedDescription)" + ) +} + +/// Finds exactly one complete `Restore` directory in a common ZIP layout. +private func restoreDirectory(in extractedDirectory: URL) throws -> URL { + let direct = extractedDirectory.appendingPathComponent( + "Restore", + isDirectory: true + ) + if isCompleteRestoreDirectory(direct) { + return direct + } + + let children: [URL] + do { + children = try FileManager.default.contentsOfDirectory( + at: extractedDirectory, + includingPropertiesForKeys: [ + .isDirectoryKey, + .isSymbolicLinkKey, + ], + options: [.skipsHiddenFiles] + ) + } catch { + throw RorkDeviceError.invalidInput( + "Could not inspect Developer Disk Image archive: \(error.localizedDescription)" + ) + } + let candidates = children.compactMap { child -> URL? in + let values = try? child.resourceValues( + forKeys: [.isDirectoryKey, .isSymbolicLinkKey] + ) + guard values?.isDirectory == true, + values?.isSymbolicLink != true + else { + return nil + } + let candidate = child.appendingPathComponent( + "Restore", + isDirectory: true + ) + return isCompleteRestoreDirectory(candidate) + ? candidate + : nil + } + guard candidates.count == 1, + let candidate = candidates.first + else { + throw RorkDeviceError.invalidInput( + "Developer Disk Image archive must contain one Restore/BuildManifest.plist." + ) + } + return candidate +} + +/// Checks the cache marker without following a symbolic-link manifest. +private func isCompleteRestoreDirectory(_ directory: URL) -> Bool { + let manifestURL = directory.appendingPathComponent( + "BuildManifest.plist" + ) + guard let values = try? manifestURL.resourceValues( + forKeys: [.isRegularFileKey, .isSymbolicLinkKey] + ) else { + return false + } + return values.isRegularFile == true + && values.isSymbolicLink != true +} diff --git a/Sources/RorkDevice/DeveloperDiskImage/HTTPSOnlyURLSessionDelegate.swift b/Sources/RorkDevice/DeveloperDiskImage/HTTPSOnlyURLSessionDelegate.swift new file mode 100644 index 0000000..0b3ee90 --- /dev/null +++ b/Sources/RorkDevice/DeveloperDiskImage/HTTPSOnlyURLSessionDelegate.swift @@ -0,0 +1,39 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +#if canImport(FoundationNetworking) || canImport(Darwin) +/// Prevents URLSession requests from following redirects to plaintext HTTP. +/// +/// Initial request URLs are validated by their callers. Centralizing redirect +/// handling keeps archive downloads and Apple TSS requests on authenticated +/// transport after the first response. +class HTTPSOnlyURLSessionDelegate: + NSObject, + URLSessionTaskDelegate, + @unchecked Sendable +{ + /// Returns the proposed redirect only when it preserves HTTPS transport. + static func approvedRedirectRequest( + _ request: URLRequest + ) -> URLRequest? { + guard request.url?.scheme?.lowercased() == "https" else { + return nil + } + return request + } + + /// Rejects redirects that would downgrade an authenticated HTTPS request. + func urlSession( + _: URLSession, + task _: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(Self.approvedRedirectRequest(request)) + } +} +#endif diff --git a/Sources/RorkDevice/DeveloperDiskImage/MobileImageMounterClient.swift b/Sources/RorkDevice/DeveloperDiskImage/MobileImageMounterClient.swift new file mode 100644 index 0000000..9d3e325 --- /dev/null +++ b/Sources/RorkDevice/DeveloperDiskImage/MobileImageMounterClient.swift @@ -0,0 +1,351 @@ +import Foundation + +/// Signals the service-close response used when no cached manifest is present. +/// +/// This sentinel is deliberately narrower than a generic transport error. +/// Callers may request a new ticket only when the device closes this specific +/// query, not when communication fails for another reason. +private struct PersonalizationManifestUnavailable: Error {} + +/// Protocol client for iOS 17+ personalized image-mounter commands. +private struct MobileImageMounterClient { + /// Image type Apple uses for personalized developer services. + private static let personalizedImageType = "DeveloperDiskImage" + + /// Active `com.apple.mobile.mobile_image_mounter` byte stream. + private let connection: DeviceConnection + + /// Creates a client over one image-mounter service connection. + init(connection: DeviceConnection) { + self.connection = connection + } + + /// Reports whether any personalized Developer Disk Image is mounted. + func isPersonalizedImageMounted() async throws -> Bool { + try await send([ + "Command": "LookupImage", + "ImageType": "Personalized", + ]) + let response = try await receive(command: "LookupImage") + + if let isPresent = response.bool("ImagePresent") { + return isPresent + } + if let signature = response["ImageSignature"] as? Data { + return !signature.isEmpty + } + if let signatures = response["ImageSignature"] as? [Data] { + return signatures.contains { !$0.isEmpty } + } + if let signatures = response["ImageSignature"] as? [Any] { + return signatures.contains { + ($0 as? Data)?.isEmpty == false + } + } + throw RorkDeviceError.protocolViolation( + "LookupImage response did not report whether a personalized image is mounted." + ) + } + + /// Reads the hardware values needed to select a matching build identity. + func personalizationIdentifiers() async throws + -> PersonalizationIdentifiers + { + try await send([ + "Command": "QueryPersonalizationIdentifiers", + "PersonalizedImageType": Self.personalizedImageType, + ]) + let response = try await receive( + command: "QueryPersonalizationIdentifiers" + ) + guard let values = response["PersonalizationIdentifiers"] + as? [String: Any], + let boardID = propertyListUInt64(values["BoardId"]), + let chipID = propertyListUInt64(values["ChipID"]), + let securityDomain = propertyListUInt64( + values["SecurityDomain"] + ) + else { + throw RorkDeviceError.protocolViolation( + "QueryPersonalizationIdentifiers response was missing hardware identifiers." + ) + } + return PersonalizationIdentifiers( + boardID: boardID, + chipID: chipID, + securityDomain: securityDomain, + additionalTSSParameters: values.filter { + $0.key.hasPrefix("Ap,") + } + ) + } + + /// Returns a cached personalization manifest for the image digest. + /// + /// The device closes this service when it has no reusable manifest. Other + /// transport failures and explicit device errors remain actionable errors. + func personalizationManifest(for imageDigest: Data) async throws -> Data { + try await send([ + "Command": "QueryPersonalizationManifest", + "PersonalizedImageType": Self.personalizedImageType, + "ImageType": Self.personalizedImageType, + "ImageSignature": imageDigest, + ]) + + let response: [String: Any] + do { + response = try await receive( + command: "QueryPersonalizationManifest" + ) + } catch { + guard isPeerClosedConnectionError(error) else { + throw error + } + throw PersonalizationManifestUnavailable() + } + guard let signature = response["ImageSignature"] as? Data, + !signature.isEmpty + else { + throw RorkDeviceError.protocolViolation( + "QueryPersonalizationManifest response did not contain ImageSignature." + ) + } + return signature + } + + /// Returns a fresh nonce for an Apple TSS personalization request. + func personalizationNonce() async throws -> Data { + try await send([ + "Command": "QueryNonce", + "HostProcessName": "CoreDeviceService", + "PersonalizedImageType": Self.personalizedImageType, + ]) + let response = try await receive(command: "QueryNonce") + guard let nonce = response["PersonalizationNonce"] as? Data, + !nonce.isEmpty + else { + throw RorkDeviceError.protocolViolation( + "QueryNonce response did not contain PersonalizationNonce." + ) + } + return nonce + } + + /// Streams the disk image after the device accepts its size and ticket. + /// + /// The image stays file-backed so a multi-gigabyte DDI is never retained + /// in memory as one `Data` value. + func uploadImage( + at imageURL: URL, + ticket: Data + ) async throws { + let handle: FileHandle + do { + handle = try FileHandle(forReadingFrom: imageURL) + } catch { + throw RorkDeviceError.invalidInput( + "Could not open Developer Disk Image: \(error.localizedDescription)" + ) + } + defer { + try? handle.close() + } + + let imageSize: UInt64 + do { + imageSize = try handle.seekToEnd() + try handle.seek(toOffset: 0) + } catch { + throw RorkDeviceError.invalidInput( + "Could not read Developer Disk Image size: \(error.localizedDescription)" + ) + } + guard imageSize > 0 else { + throw RorkDeviceError.invalidInput( + "Developer Disk Image is empty." + ) + } + + try await send([ + "Command": "ReceiveBytes", + "ImageSignature": ticket, + "ImageSize": imageSize, + "ImageType": "Personalized", + ]) + let acknowledgement = try await receive(command: "ReceiveBytes") + guard acknowledgement.string("Status") == "ReceiveBytesAck" else { + throw RorkDeviceError.protocolViolation( + "ReceiveBytes response did not acknowledge the image upload." + ) + } + + do { + while let chunk = try handle.read(upToCount: 1024 * 1024), + !chunk.isEmpty + { + try await connection.send(chunk) + } + } catch let error as RorkDeviceError { + throw error + } catch { + throw RorkDeviceError.transport( + "Developer Disk Image upload failed: \(error.localizedDescription)" + ) + } + let completion = try await receive(command: "image upload") + guard completion.string("Status") == "Complete" else { + throw RorkDeviceError.protocolViolation( + "Developer Disk Image upload did not complete." + ) + } + } + + /// Mounts an uploaded image with its ticket and validated trust cache. + func mount(ticket: Data, trustCache: Data) async throws { + try await send([ + "Command": "MountImage", + "ImageSignature": ticket, + "ImageType": "Personalized", + "ImageTrustCache": trustCache, + ]) + let response = try await receive(command: "MountImage") + guard response.string("Status") == "Complete" else { + throw RorkDeviceError.protocolViolation( + "MountImage response did not report completion." + ) + } + } + + /// Ends the image-mounter session after a successful mount. + func hangUp() async throws { + try await send(["Command": "Hangup"]) + } + + /// Sends one framed image-mounter command. + private func send(_ command: [String: Any]) async throws { + try await PropertyListMessageFramer.send( + command, + to: connection + ) + } + + /// Receives one response and preserves Apple error details in the failure. + private func receive(command: String) async throws -> [String: Any] { + let response = try await PropertyListMessageFramer.receive( + from: connection + ) + if let error = response.string("Error") { + let detail = response.string("DetailedError") + ?? response.string("ErrorDescription") + let suffix = detail.map { ": \($0)" } ?? "" + throw RorkDeviceError.lockdown( + "\(command) failed: \(error)\(suffix)" + ) + } + if let detail = response.string("DetailedError") { + throw RorkDeviceError.lockdown( + "\(command) failed: \(detail)" + ) + } + return response + } +} + +/// Returns whether the peer ended a NIO byte stream before sending a response. +private func isPeerClosedConnectionError(_ error: Error) -> Bool { + guard case RorkDeviceError.transport("Connection closed.") = error else { + return false + } + return true +} + +/// Coordinates manifest reuse, TSS fallback, upload, and mounting. +struct PersonalizedDeveloperDiskImageMounter { + /// Opens a fresh image-mounter service after a manifest cache miss. + private let openConnection: () async throws -> DeviceConnection + + /// Requests an Apple ticket when the device has no reusable manifest. + private let ticketRequester: any DeveloperDiskImageTicketRequesting + + /// Creates a mounter with replaceable connection and ticket boundaries. + init( + openConnection: @escaping () async throws -> DeviceConnection, + ticketRequester: any DeveloperDiskImageTicketRequesting + ) { + self.openConnection = openConnection + self.ticketRequester = ticketRequester + } + + /// Reuses a cached ticket when possible, otherwise requests one from TSS. + /// + /// A manifest cache miss closes the initial service, so the nonce and + /// upload sequence must continue on a freshly opened connection. + func mount( + _ image: PersonalizedDeveloperDiskImage, + ecid: UInt64 + ) async throws -> DeveloperDiskImageMountResult { + let initialConnection = try await openConnection() + var connection: DeviceConnection? = initialConnection + defer { + connection?.close() + } + + var client = MobileImageMounterClient( + connection: initialConnection + ) + if try await client.isPersonalizedImageMounted() { + return .alreadyMounted + } + + let identifiers = try await client.personalizationIdentifiers() + let payload = try image.payload(matching: identifiers) + let ticket: Data + let ticketSource: DeveloperDiskImageMountResult.TicketSource + + do { + ticket = try await client.personalizationManifest( + for: payload.imageDigest + ) + ticketSource = .deviceManifest + } catch is PersonalizationManifestUnavailable { + connection?.close() + connection = nil + + let replacementConnection = try await openConnection() + connection = replacementConnection + client = MobileImageMounterClient( + connection: replacementConnection + ) + let nonce = try await client.personalizationNonce() + ticket = try await ticketRequester.ticket( + for: payload.buildIdentity, + identifiers: identifiers, + nonce: nonce, + ecid: ecid + ) + ticketSource = .appleTSS + } + + try await client.uploadImage( + at: payload.imageURL, + ticket: ticket + ) + let trustCache: Data + do { + trustCache = try Data( + contentsOf: payload.trustCacheURL, + options: .mappedIfSafe + ) + } catch { + throw RorkDeviceError.invalidInput( + "Could not read Developer Disk Image trust cache: \(error.localizedDescription)" + ) + } + try await client.mount( + ticket: ticket, + trustCache: trustCache + ) + try await client.hangUp() + return .mounted(ticketSource: ticketSource) + } +} diff --git a/Sources/RorkDevice/DeveloperDiskImage/PersonalizedDeveloperDiskImage.swift b/Sources/RorkDevice/DeveloperDiskImage/PersonalizedDeveloperDiskImage.swift new file mode 100644 index 0000000..e6a74ab --- /dev/null +++ b/Sources/RorkDevice/DeveloperDiskImage/PersonalizedDeveloperDiskImage.swift @@ -0,0 +1,268 @@ +import Crypto +import Foundation + +/// Hardware values returned by the personalized image-mounter service. +struct PersonalizationIdentifiers { + /// Board identifier used to select a build identity. + let boardID: UInt64 + + /// Chip identifier used to select a build identity. + let chipID: UInt64 + + /// Apple security domain used to select a build identity. + let securityDomain: UInt64 + + /// Additional `Ap,` values copied into Apple TSS requests. + let additionalTSSParameters: [String: Any] +} + +/// One hardware-specific build identity from `BuildManifest.plist`. +struct DeveloperDiskImageBuildIdentity { + /// Board identifier represented by this build identity. + let boardID: UInt64 + + /// Chip identifier represented by this build identity. + let chipID: UInt64 + + /// Apple security domain represented by this build identity. + let securityDomain: UInt64 + + /// Complete build-identity property list used to populate Apple TSS fields. + let propertyList: [String: Any] + + /// Manifest entries eligible for the Apple TSS ticket request. + let manifestEntries: [String: Any] +} + +/// Files and manifest values selected for one connected device. +struct PersonalizedDeveloperDiskImagePayload { + /// Personalized disk image selected by the build identity. + let imageURL: URL + + /// Validated SHA-384 digest sent to the device and Apple TSS. + let imageDigest: Data + + /// Trust cache paired with the personalized disk image. + let trustCacheURL: URL + + /// Build identity used to request or reuse a personalization ticket. + let buildIdentity: DeveloperDiskImageBuildIdentity +} + +/// Parsed iOS 17+ personalized Developer Disk Image Restore directory. +struct PersonalizedDeveloperDiskImage { + /// Root containing the build manifest and all referenced files. + private let restoreDirectory: URL + + /// Hardware-specific identities decoded from `BuildManifest.plist`. + private let buildIdentities: [[String: Any]] + + /// Parses and validates the manifest structure in a Restore directory. + init(contentsOf restoreDirectory: URL) throws { + let restoreDirectory = restoreDirectory.standardizedFileURL + let manifestURL = restoreDirectory.appendingPathComponent( + "BuildManifest.plist" + ) + let manifestData: Data + do { + manifestData = try Data(contentsOf: manifestURL) + } catch { + throw RorkDeviceError.invalidInput( + "Could not read Developer Disk Image BuildManifest.plist: \(error.localizedDescription)" + ) + } + guard let manifest = try PropertyListCodec.decode(manifestData) + as? [String: Any], + let buildIdentities = manifest["BuildIdentities"] + as? [[String: Any]], + !buildIdentities.isEmpty + else { + throw RorkDeviceError.invalidInput( + "Developer Disk Image BuildManifest.plist does not contain build identities." + ) + } + self.restoreDirectory = restoreDirectory + self.buildIdentities = buildIdentities + } + + /// Selects and authenticates the files for the connected hardware. + /// + /// - Throws: An input error when no build identity matches, a manifest + /// entry is incomplete, a path escapes `Restore`, or a digest differs. + func payload( + matching identifiers: PersonalizationIdentifiers + ) throws -> PersonalizedDeveloperDiskImagePayload { + guard let values = buildIdentities.first(where: { + propertyListUInt64($0["ApBoardID"]) == identifiers.boardID + && propertyListUInt64($0["ApChipID"]) == identifiers.chipID + && propertyListUInt64($0["ApSecurityDomain"]) + == identifiers.securityDomain + }), + let manifest = values["Manifest"] as? [String: Any] + else { + throw RorkDeviceError.invalidInput( + "The Developer Disk Image does not support board \(hexadecimal(identifiers.boardID)), chip \(hexadecimal(identifiers.chipID)), security domain \(identifiers.securityDomain)." + ) + } + + let imageEntry = manifest["PersonalizedDMG"] + ?? manifest["PersonalizedDmg"] + guard let imageEntry = imageEntry as? [String: Any] else { + throw RorkDeviceError.invalidInput( + "Developer Disk Image manifest is missing PersonalizedDMG." + ) + } + guard let trustCacheEntry = manifest["LoadableTrustCache"] + as? [String: Any] + else { + throw RorkDeviceError.invalidInput( + "Developer Disk Image manifest is missing LoadableTrustCache." + ) + } + + let imageURL = try fileURL(for: imageEntry) + let trustCacheURL = try fileURL(for: trustCacheEntry) + let imageDigest = try validatedDigest( + for: imageURL, + manifestEntry: imageEntry, + description: "Developer Disk Image" + ) + _ = try validatedDigest( + for: trustCacheURL, + manifestEntry: trustCacheEntry, + description: "Developer Disk Image trust cache" + ) + return PersonalizedDeveloperDiskImagePayload( + imageURL: imageURL, + imageDigest: imageDigest, + trustCacheURL: trustCacheURL, + buildIdentity: DeveloperDiskImageBuildIdentity( + boardID: identifiers.boardID, + chipID: identifiers.chipID, + securityDomain: identifiers.securityDomain, + propertyList: values, + manifestEntries: manifest + ) + ) + } + + /// Resolves one manifest path without allowing traversal or symlink escape. + private func fileURL(for entry: [String: Any]) throws -> URL { + guard let info = entry["Info"] as? [String: Any], + let relativePath = info["Path"] as? String, + !relativePath.isEmpty + else { + throw RorkDeviceError.invalidInput( + "Developer Disk Image manifest entry is missing its file path." + ) + } + guard !relativePath.hasPrefix("/") else { + throw escapedPathError(relativePath) + } + + let candidate = restoreDirectory.appendingPathComponent(relativePath) + .standardizedFileURL + guard candidate.isContained(in: restoreDirectory) else { + throw escapedPathError(relativePath) + } + + // Standardizing removes `..`, while resolving symlinks prevents a + // manifest-controlled link inside Restore from reaching another tree. + let resolvedRoot = restoreDirectory.resolvingSymlinksInPath() + let resolvedCandidate = candidate.resolvingSymlinksInPath() + guard resolvedCandidate.isContained(in: resolvedRoot) else { + throw escapedPathError(relativePath) + } + let values = try resolvedCandidate.resourceValues( + forKeys: [.isRegularFileKey, .isSymbolicLinkKey] + ) + guard values.isRegularFile == true, + values.isSymbolicLink != true + else { + throw RorkDeviceError.invalidInput( + "Developer Disk Image manifest file is missing or is not a regular file: \(relativePath)" + ) + } + return resolvedCandidate + } + + /// Builds the consistent diagnostic used for every path-containment check. + private func escapedPathError(_ path: String) -> RorkDeviceError { + .invalidInput( + "Developer Disk Image manifest path escapes the Restore directory: \(path)" + ) + } +} + +/// Returns an exact integer from plist numbers or hexadecimal/decimal strings. +func propertyListUInt64(_ value: Any?) -> UInt64? { + if let value = value as? UInt64 { + return value + } + if let value = value as? NSNumber { + let integer = value.uint64Value + guard value.doubleValue == Double(integer) else { + return nil + } + return integer + } + guard let value = value as? String else { + return nil + } + if value.lowercased().hasPrefix("0x") { + return UInt64(value.dropFirst(2), radix: 16) + } + return UInt64(value) +} + +/// Produces the uppercase hexadecimal notation used in hardware diagnostics. +private func hexadecimal(_ value: UInt64) -> String { + "0x\(String(value, radix: 16, uppercase: true))" +} + +/// Validates one extracted payload against its SHA-384 manifest digest. +private func validatedDigest( + for fileURL: URL, + manifestEntry: [String: Any], + description: String +) throws -> Data { + guard let expectedDigest = manifestEntry["Digest"] as? Data, + expectedDigest.count == SHA384.Digest.byteCount + else { + throw RorkDeviceError.invalidInput( + "\(description) manifest entry has an invalid SHA-384 digest." + ) + } + let digest = try sha384Digest(of: fileURL) + guard digest == expectedDigest else { + throw RorkDeviceError.invalidInput( + "\(description) digest does not match BuildManifest.plist." + ) + } + return digest +} + +/// Hashes a file incrementally so disk images are not loaded into memory. +func sha384Digest(of fileURL: URL) throws -> Data { + let handle = try FileHandle(forReadingFrom: fileURL) + defer { + try? handle.close() + } + var hasher = SHA384() + while let chunk = try handle.read(upToCount: 1024 * 1024), + !chunk.isEmpty + { + hasher.update(data: chunk) + } + return Data(hasher.finalize()) +} + +private extension URL { + /// Checks path containment on component boundaries after standardization. + func isContained(in directory: URL) -> Bool { + let directoryPath = directory.standardizedFileURL.path + let candidatePath = standardizedFileURL.path + return candidatePath == directoryPath + || candidatePath.hasPrefix(directoryPath + "/") + } +} diff --git a/Sources/RorkDevice/Lockdown/LockdownPairingMaterial.swift b/Sources/RorkDevice/Lockdown/LockdownPairingMaterial.swift index e964804..68d2cd1 100644 --- a/Sources/RorkDevice/Lockdown/LockdownPairingMaterial.swift +++ b/Sources/RorkDevice/Lockdown/LockdownPairingMaterial.swift @@ -1,4 +1,4 @@ -#if canImport(CryptoExtras) && canImport(X509) +#if !os(WASI) import CryptoExtras import Foundation import X509 diff --git a/Sources/RorkDevice/Public/DeveloperDiskImageMountResult.swift b/Sources/RorkDevice/Public/DeveloperDiskImageMountResult.swift new file mode 100644 index 0000000..d72339d --- /dev/null +++ b/Sources/RorkDevice/Public/DeveloperDiskImageMountResult.swift @@ -0,0 +1,62 @@ +/// Outcome of mounting an iOS 17+ personalized Developer Disk Image. +/// +/// The cases preserve the relationship between mount status and ticket origin: +/// an already-mounted image has no ticket source, while a new mount always +/// records the source of its personalization ticket. +public enum DeveloperDiskImageMountResult: Equatable, Sendable { + /// Whether the image was mounted by this operation or was already present. + public enum Status: String, Codable, Sendable { + /// The operation uploaded and mounted the image. + case mounted + + /// The device already had a personalized image mounted. + case alreadyMounted + } + + /// Origin of the personalization ticket used for a new mount. + public enum TicketSource: String, Codable, Sendable { + /// The device reused a previously issued personalization manifest. + case deviceManifest + + /// Apple TSS issued a new personalization ticket. + case appleTSS + } + + /// The device already had a personalized image mounted. + case alreadyMounted + + /// The operation uploaded and mounted the image with the supplied ticket. + /// + /// - Parameter ticketSource: Origin of the personalization ticket accepted + /// by the device. + case mounted(ticketSource: TicketSource) + + /// Final mount status. + public var status: Status { + switch self { + case .alreadyMounted: + return .alreadyMounted + case .mounted: + return .mounted + } + } + + /// Ticket origin, or `nil` when the image was already mounted. + public var ticketSource: TicketSource? { + switch self { + case .alreadyMounted: + return nil + case let .mounted(ticketSource): + return ticketSource + } + } + + /// Whether an existing CoreDevice tunnel must be recreated. + /// + /// Remote Service Discovery captures its service list when the tunnel + /// opens. A newly mounted image adds developer services, so callers must + /// create a fresh tunnel before trying to use those services. + public var requiresTunnelRestart: Bool { + status == .mounted + } +} diff --git a/Sources/RorkDevice/Public/DeviceSession.swift b/Sources/RorkDevice/Public/DeviceSession.swift index 71c0603..24d3cd8 100644 --- a/Sources/RorkDevice/Public/DeviceSession.swift +++ b/Sources/RorkDevice/Public/DeviceSession.swift @@ -13,6 +13,11 @@ import Foundation /// retain a session for a complete install workflow without managing individual /// service ports or protocol handshakes. public final class DeviceSession { + /// Kept outside `LockdownServiceName` because new public enum cases break + /// exhaustive switches in existing clients. + private static let personalizedDeveloperDiskImageServiceName = + "com.apple.mobile.mobile_image_mounter" + /// Backend that resolves and opens services for this session's transport. private let backend: DeviceSessionBackend @@ -102,6 +107,114 @@ public final class DeviceSession { ).reveal() } + /// Mounts an iOS 17+ personalized Developer Disk Image. + /// + /// `restoreDirectory` must contain `BuildManifest.plist` and the files it + /// references. The session queries the connected device for its hardware + /// identity, reuses a device-side personalization manifest when possible, + /// and otherwise requests a fresh ticket from Apple TSS. + /// + /// Call this through a Lockdown-backed session before opening a CoreDevice + /// tunnel. If `requiresTunnelRestart` is `true`, any existing tunnel must + /// be recreated so Remote Service Discovery advertises the mounted image's + /// developer services. + /// + /// - Parameter restoreDirectory: Extracted personalized DDI `Restore` + /// directory. + /// - Returns: Mount status and personalization-ticket origin. + /// - Throws: An input error for an unsupported device, disabled Developer + /// Mode, or invalid image; a protocol or transport error when Lockdown, + /// image mounter, or Apple TSS cannot complete the operation. + public func mountPersonalizedDeveloperDiskImage( + from restoreDirectory: URL + ) async throws -> DeveloperDiskImageMountResult { + let image = try PersonalizedDeveloperDiskImage( + contentsOf: restoreDirectory + ) + let ecid = try await developerDiskImageECID() + return try await mountPersonalizedDeveloperDiskImage( + image, + ecid: ecid + ) + } + + /// Downloads and mounts a personalized Developer Disk Image archive. + /// + /// The archive is authenticated with the source's pinned SHA-256 before + /// extraction. Applications remain responsible for selecting a lawful, + /// trustworthy archive provider. + /// + /// - Parameters: + /// - source: HTTPS archive and expected digest. + /// - store: Download and extraction cache. + /// - Returns: Mount status and personalization-ticket origin. + /// - Throws: An input error for an unsupported device, disabled Developer + /// Mode, invalid archive, or incompatible image; a protocol or transport + /// error when the archive host, Lockdown, image mounter, or Apple TSS + /// cannot complete the operation. + public func mountPersonalizedDeveloperDiskImage( + from source: DeveloperDiskImageSource, + using store: DeveloperDiskImageStore = DeveloperDiskImageStore() + ) async throws -> DeveloperDiskImageMountResult { + let ecid = try await developerDiskImageECID() + let restoreDirectory = try await store.prepareRestoreDirectory( + from: source + ) + let image = try PersonalizedDeveloperDiskImage( + contentsOf: restoreDirectory + ) + return try await mountPersonalizedDeveloperDiskImage( + image, + ecid: ecid + ) + } + + /// Validates device state before network or image-mounter work begins. + private func developerDiskImageECID() async throws -> UInt64 { + let deviceInfo = try await fetchDeviceInfo() + guard let productVersion = deviceInfo.productVersion, + let majorVersion = productVersion.split(separator: ".").first + .flatMap({ Int($0) }) + else { + throw RorkDeviceError.protocolViolation( + "Lockdown did not report a valid ProductVersion for Developer Disk Image mounting." + ) + } + guard majorVersion >= 17 else { + throw RorkDeviceError.invalidInput( + "Personalized Developer Disk Images require iOS 17 or newer." + ) + } + guard try await isDeveloperModeEnabled() else { + throw RorkDeviceError.invalidInput( + "Developer Mode must be enabled before mounting a personalized Developer Disk Image." + ) + } + guard let ecid = propertyListUInt64( + deviceInfo.rawValues["UniqueChipID"] + ) else { + throw RorkDeviceError.protocolViolation( + "Lockdown did not report a valid UniqueChipID for Developer Disk Image personalization." + ) + } + return ecid + } + + /// Mounts a parsed image after device compatibility has been established. + private func mountPersonalizedDeveloperDiskImage( + _ image: PersonalizedDeveloperDiskImage, + ecid: UInt64 + ) async throws -> DeveloperDiskImageMountResult { + return try await PersonalizedDeveloperDiskImageMounter( + openConnection: { + try await self.startService( + named: Self.personalizedDeveloperDiskImageServiceName + ) + }, + ticketRequester: AppleTSSClient() + ).mount(image, ecid: ecid) + } + /// Opens a modeled device service on the active session route. /// /// This overload covers services modeled by rork-device. Use diff --git a/Sources/RorkDevice/Public/RorkDevice.swift b/Sources/RorkDevice/Public/RorkDevice.swift index d2a3305..97510e4 100644 --- a/Sources/RorkDevice/Public/RorkDevice.swift +++ b/Sources/RorkDevice/Public/RorkDevice.swift @@ -162,7 +162,7 @@ public final class DeviceClient { /// - Throws: `LockdownPairingError` for user decisions and timeout, /// `RorkDeviceError.invalidInput` for unsupported routes, or underlying /// transport and certificate errors. - #if canImport(CryptoExtras) && canImport(X509) + #if !os(WASI) public func pair( with device: Device, trustTimeout: Duration = .seconds(120), diff --git a/Sources/RorkDeviceCLI/RorkDeviceCommand.swift b/Sources/RorkDeviceCLI/RorkDeviceCommand.swift index 8209497..71bd639 100644 --- a/Sources/RorkDeviceCLI/RorkDeviceCommand.swift +++ b/Sources/RorkDeviceCLI/RorkDeviceCommand.swift @@ -15,6 +15,7 @@ struct RorkDeviceCommand: AsyncParsableCommand { Info.self, PairingCommand.self, DeveloperModeCommand.self, + ImageCommand.self, Apps.self, Files.self, Install.self, @@ -723,6 +724,178 @@ struct DeveloperModeReveal: AsyncParsableCommand { } } +/// Groups personalized Developer Disk Image operations. +struct ImageCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "image", + abstract: "Mount personalized Developer Disk Images.", + subcommands: [ + ImageMount.self, + ImageAuto.self, + ] + ) +} + +/// Mounts an already extracted personalized DDI Restore directory. +struct ImageMount: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "mount", + abstract: "Mount a local personalized DDI Restore directory." + ) + + @OptionGroup var connection: ConnectionOptions + + @Option( + name: .customLong("path"), + help: "Path to the extracted DDI Restore directory." + ) + var restorePath: String + + @Flag(help: "Emit machine-readable JSON.") + var json = false + + func validate() throws { + try connection.requireLockdownRoute(for: "image mount") + guard !restorePath.trimmingCharacters( + in: .whitespacesAndNewlines + ).isEmpty else { + throw ValidationError("--path cannot be empty.") + } + } + + func run() async throws { + let session = try await connection.session() + let result = try await session + .mountPersonalizedDeveloperDiskImage( + from: URL( + fileURLWithPath: restorePath, + isDirectory: true + ) + ) + try writeDeveloperDiskImageMountResult( + result, + asJSON: json + ) + } +} + +/// Downloads, authenticates, caches, and mounts a personalized DDI archive. +struct ImageAuto: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "auto", + abstract: "Download and mount an authenticated personalized DDI archive." + ) + + @OptionGroup var connection: ConnectionOptions + + @Option(help: "HTTPS URL of the personalized DDI ZIP archive.") + var archiveURL: String + + @Option(help: "Expected SHA-256 of the ZIP archive.") + var sha256: String + + @Option(help: "Override the extracted DDI cache directory.") + var cacheDirectory: String? + + @Flag(help: "Emit machine-readable JSON.") + var json = false + + func validate() throws { + try connection.requireLockdownRoute(for: "image auto") + _ = try source() + _ = try store() + } + + func run() async throws { + let session = try await connection.session() + let result = try await session + .mountPersonalizedDeveloperDiskImage( + from: source(), + using: store() + ) + try writeDeveloperDiskImageMountResult( + result, + asJSON: json + ) + } + + /// Validates the user-supplied archive URL and pinned digest together. + private func source() throws -> DeveloperDiskImageSource { + guard let url = URL(string: archiveURL) else { + throw ValidationError( + "--archive-url must be a valid HTTPS URL." + ) + } + return try DeveloperDiskImageSource( + archiveURL: url, + expectedSHA256: sha256 + ) + } + + /// Builds either the default store or a validated cache override. + private func store() throws -> DeveloperDiskImageStore { + guard let cacheDirectory else { + return DeveloperDiskImageStore() + } + guard !cacheDirectory.trimmingCharacters( + in: .whitespacesAndNewlines + ).isEmpty else { + throw ValidationError( + "--cache-directory cannot be empty." + ) + } + return DeveloperDiskImageStore( + cacheDirectory: URL( + fileURLWithPath: cacheDirectory, + isDirectory: true + ) + ) + } +} + +/// Encodes the stable machine-readable image-mount result. +func developerDiskImageMountJSON( + _ result: DeveloperDiskImageMountResult +) throws -> Data { + let output = DeveloperDiskImageMountOutput( + status: result.status, + ticketSource: result.ticketSource, + requiresTunnelRestart: result.requiresTunnelRestart + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(output) +} + +/// Writes image-mount output without mixing human text into JSON mode. +private func writeDeveloperDiskImageMountResult( + _ result: DeveloperDiskImageMountResult, + asJSON: Bool +) throws { + if asJSON { + try FileHandle.standardOutput.write( + contentsOf: developerDiskImageMountJSON(result) + ) + try FileHandle.standardOutput.write(contentsOf: Data([0x0a])) + return + } + switch result.status { + case .alreadyMounted: + print("Personalized Developer Disk Image is already mounted.") + case .mounted: + print( + "Personalized Developer Disk Image mounted. Recreate any existing CoreDevice tunnel before using developer services." + ) + } +} + +/// Stable JSON shape emitted by `image mount` and `image auto`. +private struct DeveloperDiskImageMountOutput: Encodable { + let status: DeveloperDiskImageMountResult.Status + let ticketSource: DeveloperDiskImageMountResult.TicketSource? + let requiresTunnelRestart: Bool +} + /// Parent command for remote-pairing identity operations. struct RemotePairingCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 0b92e23..111a8f0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -12,14 +12,15 @@ that must accompany binary distributions are preserved under ## Apache License 2.0 Components - [Swift Argument Parser 1.8.1](https://github.com/apple/swift-argument-parser) -- [SwiftNIO 2.100.0](https://github.com/apple/swift-nio) -- [SwiftNIO SSL 2.37.1](https://github.com/apple/swift-nio-ssl) -- [Swift Certificates 1.10.1](https://github.com/apple/swift-certificates) -- [Swift Crypto 3.12.5](https://github.com/apple/swift-crypto) +- [SwiftNIO 2.100.0-rork.1](https://github.com/rorkai/swift-nio) +- [SwiftNIO SSL 2.37.1-rork.1](https://github.com/rorkai/swift-nio-ssl) +- [Swift Certificates 1.19.1-rork.1](https://github.com/rorkai/swift-certificates) +- [Swift Crypto 4.5.0-rork.1](https://github.com/rorkai/swift-crypto) - [Swift ASN.1 1.7.1](https://github.com/apple/swift-asn1) - [Swift Atomics 1.3.0](https://github.com/apple/swift-atomics) -- [Swift Collections 1.5.1](https://github.com/apple/swift-collections) -- [Swift System 1.6.4](https://github.com/apple/swift-system) +- [Swift Collections 1.6.0](https://github.com/apple/swift-collections) +- [Swift System 1.7.2](https://github.com/apple/swift-system) +- [Swift ZIP Archive a611fb9](https://github.com/rorkai/swift-zip-archive/commit/a611fb98910fc3b933b03c57b19a379af7efe7cf) The upstream attribution notices that apply to these components and their incorporated works are reproduced in: diff --git a/Tests/RorkDeviceCLITests/RorkDeviceCLITests.swift b/Tests/RorkDeviceCLITests/RorkDeviceCLITests.swift index 49b70bc..3201559 100644 --- a/Tests/RorkDeviceCLITests/RorkDeviceCLITests.swift +++ b/Tests/RorkDeviceCLITests/RorkDeviceCLITests.swift @@ -16,10 +16,86 @@ final class RorkDeviceCLITests: XCTestCase { XCTAssertTrue(help.contains("profiles")) XCTAssertTrue(help.contains("pairing")) XCTAssertTrue(help.contains("developer-mode")) + XCTAssertTrue(help.contains("image")) XCTAssertTrue(help.contains("remote-pairing")) XCTAssertTrue(help.contains("tunnel")) } + func testImageMountCommandParsesRestorePath() throws { + let command = try ImageMount.parse([ + "--pairing-record", "pairing.plist", + "--path", "/tmp/DDI/Restore", + "--json", + ]) + + XCTAssertEqual( + command.connection.pairingRecord, + "pairing.plist" + ) + XCTAssertEqual(command.restorePath, "/tmp/DDI/Restore") + XCTAssertTrue(command.json) + } + + func testImageAutoCommandParsesAuthenticatedSource() throws { + let command = try ImageAuto.parse([ + "--pairing-record", "pairing.plist", + "--archive-url", "https://example.com/ddi.zip", + "--sha256", String(repeating: "a", count: 64), + "--cache-directory", "/tmp/ddi-cache", + "--json", + ]) + + XCTAssertEqual( + command.archiveURL, + "https://example.com/ddi.zip" + ) + XCTAssertEqual( + command.sha256, + String(repeating: "a", count: 64) + ) + XCTAssertEqual(command.cacheDirectory, "/tmp/ddi-cache") + XCTAssertTrue(command.json) + } + + func testImageAutoCommandRejectsInsecureSource() { + XCTAssertThrowsError(try ImageAuto.parse([ + "--pairing-record", "pairing.plist", + "--archive-url", "http://example.com/ddi.zip", + "--sha256", String(repeating: "a", count: 64), + ])) + } + + func testImageAutoCommandRejectsBlankCacheDirectory() { + for cacheDirectory in ["", " \n "] { + XCTAssertThrowsError(try ImageAuto.parse([ + "--pairing-record", "pairing.plist", + "--archive-url", "https://example.com/ddi.zip", + "--sha256", String(repeating: "a", count: 64), + "--cache-directory", cacheDirectory, + ])) + } + } + + func testDeveloperDiskImageMountJSONIncludesTunnelRestart() throws { + let data = try developerDiskImageMountJSON( + .mounted(ticketSource: .appleTSS) + ) + let output = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) + as? [String: Any] + ) + + XCTAssertEqual(output["status"] as? String, "mounted") + XCTAssertEqual( + output["ticketSource"] as? String, + "appleTSS" + ) + XCTAssertEqual( + output["requiresTunnelRestart"] as? Bool, + true + ) + } + func testInstallCommandParsesArguments() throws { let command = try Install.parse([ "--pairing-record", "pairing.plist", diff --git a/Tests/RorkDeviceTests/AppleTSSClientTests.swift b/Tests/RorkDeviceTests/AppleTSSClientTests.swift new file mode 100644 index 0000000..a247401 --- /dev/null +++ b/Tests/RorkDeviceTests/AppleTSSClientTests.swift @@ -0,0 +1,237 @@ +import Foundation +import XCTest + +@testable import RorkDevice + +final class AppleTSSClientTests: XCTestCase { + func testRequestIncludesHardwareValuesAndAppliesRestoreRules() throws { + let buildIdentity = DeveloperDiskImageBuildIdentity( + boardID: 12, + chipID: 0x8150, + securityDomain: 1, + propertyList: [:], + manifestEntries: [ + "PersonalizedDMG": [ + "Digest": Data(repeating: 0x11, count: 48), + "Info": [ + "Path": "DeveloperDiskImage.dmg", + ], + "Name": "DeveloperDiskImage", + "Trusted": true, + ], + "LoadableTrustCache": [ + "Digest": Data(repeating: 0x22, count: 48), + "Info": [ + "Path": "Firmware/DeveloperDiskImage.dmg.trustcache", + "RestoreRequestRules": [ + [ + "Actions": ["EPRO": true], + "Conditions": [ + "ApCurrentProductionMode": true, + "ApRequiresImage4": true, + ], + ], + [ + "Actions": ["ESEC": true], + "Conditions": [ + "ApRawSecurityMode": true, + "ApRequiresImage4": true, + ], + ], + ], + ], + "Trusted": true, + ], + ] + ) + let identifiers = PersonalizationIdentifiers( + boardID: 12, + chipID: 0x8150, + securityDomain: 1, + additionalTSSParameters: [ + "Ap,ProductType": "iPhone18,1", + "Ignored": "value", + ] + ) + + let request = try AppleTSSRequest( + buildIdentity: buildIdentity, + identifiers: identifiers, + nonce: Data([0x01, 0x02]), + ecid: 123, + requestIdentifier: UUID( + uuidString: "00112233-4455-6677-8899-AABBCCDDEEFF" + )! + ).propertyList + + XCTAssertEqual(request["@ApImg4Ticket"] as? Bool, true) + XCTAssertEqual(request["@BBTicket"] as? Bool, true) + XCTAssertEqual(request["@HostPlatformInfo"] as? String, "mac") + XCTAssertEqual( + request["@UUID"] as? String, + "00112233-4455-6677-8899-AABBCCDDEEFF" + ) + XCTAssertEqual(request["ApBoardID"] as? UInt64, 12) + XCTAssertEqual(request["ApChipID"] as? UInt64, 0x8150) + XCTAssertEqual(request["ApSecurityDomain"] as? UInt64, 1) + XCTAssertEqual(request["ApECID"] as? UInt64, 123) + XCTAssertEqual(request["ApNonce"] as? Data, Data([0x01, 0x02])) + XCTAssertEqual(request["Ap,ProductType"] as? String, "iPhone18,1") + XCTAssertNil(request["Ignored"]) + XCTAssertEqual((request["SepNonce"] as? Data)?.count, 20) + + let image = try XCTUnwrap( + request["PersonalizedDMG"] as? [String: Any] + ) + XCTAssertEqual(image["Name"] as? String, "DeveloperDiskImage") + XCTAssertEqual(image["EPRO"] as? Bool, true) + XCTAssertEqual(image["ESEC"] as? Bool, true) + XCTAssertNil(image["Info"]) + + let trustCache = try XCTUnwrap( + request["LoadableTrustCache"] as? [String: Any] + ) + XCTAssertEqual(trustCache["EPRO"] as? Bool, true) + XCTAssertEqual(trustCache["ESEC"] as? Bool, true) + XCTAssertNil(trustCache["Info"]) + } + + func testResponseParsesRawRequestStringPlist() throws { + let ticket = Data([0xAA, 0xBB, 0xCC]) + let plist = try PropertyListSerialization.data( + fromPropertyList: ["ApImg4Ticket": ticket], + format: .xml, + options: 0 + ) + var response = Data("STATUS=0&MESSAGE=SUCCESS&REQUEST_STRING=".utf8) + response.append(plist) + + XCTAssertEqual( + try AppleTSSResponse(data: response).ticket, + ticket + ) + } + + func testResponsePreservesServerFailureMessage() throws { + let response = Data( + "STATUS=94&MESSAGE=This device is not eligible".utf8 + ) + + XCTAssertThrowsError(try AppleTSSResponse(data: response)) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .protocolViolation( + "Apple TSS rejected the request with status 94: This device is not eligible" + ) + ) + } + } + + func testClientPostsXMLToStrictHTTPSTransport() async throws { + let ticket = Data([0x01, 0x02, 0x03]) + let responsePlist = try PropertyListSerialization.data( + fromPropertyList: ["ApImg4Ticket": ticket], + format: .xml, + options: 0 + ) + var responseData = Data( + "STATUS=0&MESSAGE=SUCCESS&REQUEST_STRING=".utf8 + ) + responseData.append(responsePlist) + let transport = RecordingTSSHTTPTransport( + response: TSSHTTPResponse( + statusCode: 200, + body: responseData + ) + ) + let client = AppleTSSClient(transport: transport) + + let result = try await client.ticket( + for: DeveloperDiskImageBuildIdentity( + boardID: 12, + chipID: 0x8150, + securityDomain: 1, + propertyList: [:], + manifestEntries: [:] + ), + identifiers: PersonalizationIdentifiers( + boardID: 12, + chipID: 0x8150, + securityDomain: 1, + additionalTSSParameters: [:] + ), + nonce: Data([0x04]), + ecid: 123 + ) + + XCTAssertEqual(result, ticket) + let request = try XCTUnwrap(transport.request) + XCTAssertEqual(request.url.scheme, "https") + XCTAssertEqual(request.url.host, "gs.apple.com") + XCTAssertEqual(request.method, "POST") + XCTAssertEqual( + request.headers["Content-Type"], + "text/xml; charset=utf-8" + ) + XCTAssertFalse(request.body.isEmpty) + } + + func testClientRejectsNonSuccessfulHTTPStatus() async throws { + let transport = RecordingTSSHTTPTransport( + response: TSSHTTPResponse( + statusCode: 503, + body: Data("unavailable".utf8) + ) + ) + let client = AppleTSSClient(transport: transport) + + await XCTAssertThrowsErrorAsync( + { + try await client.ticket( + for: DeveloperDiskImageBuildIdentity( + boardID: 12, + chipID: 0x8150, + securityDomain: 1, + propertyList: [:], + manifestEntries: [:] + ), + identifiers: PersonalizationIdentifiers( + boardID: 12, + chipID: 0x8150, + securityDomain: 1, + additionalTSSParameters: [:] + ), + nonce: Data([0x04]), + ecid: 123 + ) + }, + { error in + XCTAssertEqual( + error as? RorkDeviceError, + .transport( + "Apple TSS returned HTTP status 503." + ) + ) + } + ) + } +} + +private final class RecordingTSSHTTPTransport: + TSSHTTPTransport, + @unchecked Sendable +{ + private let response: TSSHTTPResponse + private(set) var request: TSSHTTPRequest? + + init(response: TSSHTTPResponse) { + self.response = response + } + + func response( + for request: TSSHTTPRequest + ) async throws -> TSSHTTPResponse { + self.request = request + return response + } +} diff --git a/Tests/RorkDeviceTests/DeveloperDiskImageSessionTests.swift b/Tests/RorkDeviceTests/DeveloperDiskImageSessionTests.swift new file mode 100644 index 0000000..f9bdd63 --- /dev/null +++ b/Tests/RorkDeviceTests/DeveloperDiskImageSessionTests.swift @@ -0,0 +1,239 @@ +import Foundation +import XCTest + +@testable import RorkDevice + +final class DeveloperDiskImageSessionTests: XCTestCase { + func testMountUsesMobileImageMounterForSupportedDevice() async throws { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let connection = FakeConnection( + inbound: try PropertyListMessageFramer.encode([ + "ImageSignature": [Data([0x01])], + "Status": "Complete", + ]) + ) + let backend = DeveloperDiskImageSessionTestBackend( + deviceInfo: DeviceInfo(values: [ + "ProductVersion": "18.5", + "UniqueChipID": "303775031541788", + ]), + developerModeEnabled: true, + connections: [connection] + ) + let session = DeviceSession(backend: backend) + + let result = try await session + .mountPersonalizedDeveloperDiskImage( + from: fixture.restoreDirectory + ) + + XCTAssertEqual(result.status, .alreadyMounted) + XCTAssertEqual( + backend.startedServiceNames, + ["com.apple.mobile.mobile_image_mounter"] + ) + XCTAssertTrue(connection.isClosed) + } + + func testMountRejectsPreIOS17Device() async throws { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let backend = DeveloperDiskImageSessionTestBackend( + deviceInfo: DeviceInfo(values: [ + "ProductVersion": "16.7.12", + "UniqueChipID": "123", + ]), + developerModeEnabled: true, + connections: [] + ) + let session = DeviceSession(backend: backend) + + await XCTAssertThrowsErrorAsync({ + try await session.mountPersonalizedDeveloperDiskImage( + from: fixture.restoreDirectory + ) + }) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .invalidInput( + "Personalized Developer Disk Images require iOS 17 or newer." + ) + ) + } + XCTAssertTrue(backend.startedServiceNames.isEmpty) + } + + func testAutomaticMountChecksDeviceBeforeDownloading() async throws { + let downloader = UnexpectedDeveloperDiskImageArchiveDownloader() + let cacheDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + defer { + try? FileManager.default.removeItem(at: cacheDirectory) + } + let store = DeveloperDiskImageStore( + cacheDirectory: cacheDirectory, + downloader: downloader + ) + let source = try DeveloperDiskImageSource( + archiveURL: URL(string: "https://example.com/ddi.zip")!, + expectedSHA256: String(repeating: "a", count: 64) + ) + let backend = DeveloperDiskImageSessionTestBackend( + deviceInfo: DeviceInfo(values: [ + "ProductVersion": "16.7.12", + "UniqueChipID": "123", + ]), + developerModeEnabled: true, + connections: [] + ) + let session = DeviceSession(backend: backend) + + await XCTAssertThrowsErrorAsync({ + try await session.mountPersonalizedDeveloperDiskImage( + from: source, + using: store + ) + }) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .invalidInput( + "Personalized Developer Disk Images require iOS 17 or newer." + ) + ) + } + XCTAssertEqual(downloader.downloadCount, 0) + } + + func testMountRequiresDeveloperMode() async throws { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let backend = DeveloperDiskImageSessionTestBackend( + deviceInfo: DeviceInfo(values: [ + "ProductVersion": "18.5", + "UniqueChipID": "123", + ]), + developerModeEnabled: false, + connections: [] + ) + let session = DeviceSession(backend: backend) + + await XCTAssertThrowsErrorAsync({ + try await session.mountPersonalizedDeveloperDiskImage( + from: fixture.restoreDirectory + ) + }) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .invalidInput( + "Developer Mode must be enabled before mounting a personalized Developer Disk Image." + ) + ) + } + XCTAssertTrue(backend.startedServiceNames.isEmpty) + } + + func testMountRequiresECID() async throws { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let backend = DeveloperDiskImageSessionTestBackend( + deviceInfo: DeviceInfo(values: [ + "ProductVersion": "18.5", + ]), + developerModeEnabled: true, + connections: [] + ) + let session = DeviceSession(backend: backend) + + await XCTAssertThrowsErrorAsync({ + try await session.mountPersonalizedDeveloperDiskImage( + from: fixture.restoreDirectory + ) + }) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .protocolViolation( + "Lockdown did not report a valid UniqueChipID for Developer Disk Image personalization." + ) + ) + } + XCTAssertTrue(backend.startedServiceNames.isEmpty) + } +} + +private final class UnexpectedDeveloperDiskImageArchiveDownloader: + DeveloperDiskImageArchiveDownloading, + @unchecked Sendable +{ + private(set) var downloadCount = 0 + + func download( + from _: URL, + to _: URL, + maximumByteCount _: UInt64 + ) async throws -> DeveloperDiskImageArchiveHTTPResponse { + downloadCount += 1 + throw RorkDeviceError.transport( + "Archive download should not have started." + ) + } +} + +private final class DeveloperDiskImageSessionTestBackend: + DeviceSessionBackend +{ + private let deviceInfo: DeviceInfo + private let developerModeEnabled: Bool + private var connections: [DeviceConnection] + private(set) var startedServiceNames: [String] = [] + + init( + deviceInfo: DeviceInfo, + developerModeEnabled: Bool, + connections: [DeviceConnection] + ) { + self.deviceInfo = deviceInfo + self.developerModeEnabled = developerModeEnabled + self.connections = connections + } + + func fetchDeviceInfo() async throws -> DeviceInfo { + deviceInfo + } + + func isDeveloperModeEnabled() async throws -> Bool { + developerModeEnabled + } + + func startService( + named serviceName: String, + escrowBag _: Data? + ) async throws -> DeviceConnection { + startedServiceNames.append(serviceName) + guard !connections.isEmpty else { + throw RorkDeviceError.transport( + "No image-mounter test connection remains." + ) + } + return connections.removeFirst() + } +} diff --git a/Tests/RorkDeviceTests/DeveloperDiskImageStoreTests.swift b/Tests/RorkDeviceTests/DeveloperDiskImageStoreTests.swift new file mode 100644 index 0000000..3dd05f6 --- /dev/null +++ b/Tests/RorkDeviceTests/DeveloperDiskImageStoreTests.swift @@ -0,0 +1,508 @@ +import Crypto +import Foundation +import XCTest +import ZipArchive + +@testable import RorkDevice + +final class DeveloperDiskImageStoreTests: XCTestCase { + func testSourceRequiresHTTPSAndSHA256() throws { + XCTAssertThrowsError( + try DeveloperDiskImageSource( + archiveURL: URL(string: "http://example.com/ddi.zip")!, + expectedSHA256: String(repeating: "a", count: 64) + ) + ) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .invalidInput( + "Developer Disk Image archive URL must use HTTPS." + ) + ) + } + + XCTAssertThrowsError( + try DeveloperDiskImageSource( + archiveURL: URL(string: "https://example.com/ddi.zip")!, + expectedSHA256: "not-a-digest" + ) + ) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .invalidInput( + "Developer Disk Image archive SHA-256 must contain 64 hexadecimal characters." + ) + ) + } + } + + func testPrepareRestoreDirectoryDownloadsOnceAndReusesCache() + async throws + { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let archive = try makeArchive(containing: fixture.restoreDirectory) + defer { archive.remove() } + let cacheDirectory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: cacheDirectory) } + let downloader = RecordingDeveloperDiskImageArchiveDownloader( + archiveURL: archive.url + ) + let source = try DeveloperDiskImageSource( + archiveURL: URL(string: "https://example.com/ddi.zip")!, + expectedSHA256: try sha256HexDigest(of: archive.url) + ) + let store = DeveloperDiskImageStore( + cacheDirectory: cacheDirectory, + downloader: downloader + ) + + let first = try await store.prepareRestoreDirectory(from: source) + let second = try await store.prepareRestoreDirectory(from: source) + + XCTAssertEqual(first, second) + XCTAssertEqual(first.lastPathComponent, "Restore") + XCTAssertTrue( + FileManager.default.fileExists( + atPath: first.appendingPathComponent( + "BuildManifest.plist" + ).path + ) + ) + XCTAssertEqual(downloader.downloadCount, 1) + } + + func testPrepareRestoreDirectoryRejectsHTTPFailure() async throws { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let archive = try makeArchive(containing: fixture.restoreDirectory) + defer { archive.remove() } + let cacheDirectory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: cacheDirectory) } + let downloader = RecordingDeveloperDiskImageArchiveDownloader( + archiveURL: archive.url, + statusCode: 404 + ) + let source = try DeveloperDiskImageSource( + archiveURL: URL(string: "https://example.com/ddi.zip")!, + expectedSHA256: try sha256HexDigest(of: archive.url) + ) + let store = DeveloperDiskImageStore( + cacheDirectory: cacheDirectory, + downloader: downloader + ) + + await XCTAssertThrowsErrorAsync({ + try await store.prepareRestoreDirectory(from: source) + }) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .transport( + "Developer Disk Image archive returned HTTP status 404." + ) + ) + } + } + + func testPrepareRestoreDirectoryRejectsDigestMismatch() async throws { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let archive = try makeArchive(containing: fixture.restoreDirectory) + defer { archive.remove() } + let cacheDirectory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: cacheDirectory) } + let downloader = RecordingDeveloperDiskImageArchiveDownloader( + archiveURL: archive.url + ) + let source = try DeveloperDiskImageSource( + archiveURL: URL(string: "https://example.com/ddi.zip")!, + expectedSHA256: String(repeating: "0", count: 64) + ) + let store = DeveloperDiskImageStore( + cacheDirectory: cacheDirectory, + downloader: downloader + ) + + await XCTAssertThrowsErrorAsync({ + try await store.prepareRestoreDirectory(from: source) + }) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .invalidInput( + "Developer Disk Image archive SHA-256 did not match the expected digest." + ) + ) + } + } + + func testPrepareRestoreDirectoryRejectsSymbolicLinks() async throws { + let archive = try makeSymbolicLinkArchive( + path: "Restore/image-link", + target: "DeveloperDiskImage.dmg" + ) + defer { archive.remove() } + let cacheDirectory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: cacheDirectory) } + let downloader = RecordingDeveloperDiskImageArchiveDownloader( + archiveURL: archive.url + ) + let source = try DeveloperDiskImageSource( + archiveURL: URL(string: "https://example.com/ddi.zip")!, + expectedSHA256: try sha256HexDigest(of: archive.url) + ) + let store = DeveloperDiskImageStore( + cacheDirectory: cacheDirectory, + downloader: downloader + ) + + await XCTAssertThrowsErrorAsync({ + try await store.prepareRestoreDirectory(from: source) + }) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .invalidInput( + "Developer Disk Image archive contains symbolic links." + ) + ) + } + } + + func testPrepareRestoreDirectoryEnforcesExpandedSizeLimit() + async throws + { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let archive = try makeArchive(containing: fixture.restoreDirectory) + defer { archive.remove() } + let cacheDirectory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: cacheDirectory) } + let downloader = RecordingDeveloperDiskImageArchiveDownloader( + archiveURL: archive.url + ) + let source = try DeveloperDiskImageSource( + archiveURL: URL(string: "https://example.com/ddi.zip")!, + expectedSHA256: try sha256HexDigest(of: archive.url) + ) + let store = DeveloperDiskImageStore( + cacheDirectory: cacheDirectory, + downloader: downloader, + limits: DeveloperDiskImageArchiveLimits( + maximumArchiveSize: 1024 * 1024, + maximumEntryCount: 100, + maximumExpandedSize: 1 + ) + ) + + await XCTAssertThrowsErrorAsync({ + try await store.prepareRestoreDirectory(from: source) + }) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .invalidInput( + "Developer Disk Image archive expands beyond the 1-byte limit." + ) + ) + } + } + + func testPrepareRestoreDirectoryRejectsCanonicalDuplicatePaths() + async throws + { + for alias in [ + "Restore/./BuildManifest.plist", + "Restore//BuildManifest.plist", + ] { + let archive = try makeArchive(entries: [ + ("Restore/BuildManifest.plist", Data("first".utf8)), + (alias, Data("second".utf8)), + ]) + defer { archive.remove() } + let cacheDirectory = temporaryDirectory() + defer { + try? FileManager.default.removeItem(at: cacheDirectory) + } + let downloader = RecordingDeveloperDiskImageArchiveDownloader( + archiveURL: archive.url + ) + let source = try DeveloperDiskImageSource( + archiveURL: URL(string: "https://example.com/ddi.zip")!, + expectedSHA256: try sha256HexDigest(of: archive.url) + ) + let store = DeveloperDiskImageStore( + cacheDirectory: cacheDirectory, + downloader: downloader + ) + + await XCTAssertThrowsErrorAsync({ + try await store.prepareRestoreDirectory(from: source) + }) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .invalidInput( + "Developer Disk Image archive contains an unsafe or duplicate path." + ) + ) + } + } + } + + func testPrepareRestoreDirectoryRejectsFileDirectoryPathConflicts() + async throws + { + let archive = try makeArchive(entries: [ + ("Restore", Data("file".utf8)), + ( + "Restore/BuildManifest.plist", + Data("manifest".utf8) + ), + ]) + defer { archive.remove() } + let cacheDirectory = temporaryDirectory() + defer { + try? FileManager.default.removeItem(at: cacheDirectory) + } + let downloader = RecordingDeveloperDiskImageArchiveDownloader( + archiveURL: archive.url + ) + let source = try DeveloperDiskImageSource( + archiveURL: URL(string: "https://example.com/ddi.zip")!, + expectedSHA256: try sha256HexDigest(of: archive.url) + ) + let store = DeveloperDiskImageStore( + cacheDirectory: cacheDirectory, + downloader: downloader + ) + + await XCTAssertThrowsErrorAsync({ + try await store.prepareRestoreDirectory(from: source) + }) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .invalidInput( + "Developer Disk Image archive contains an unsafe or duplicate path." + ) + ) + } + } + + func testHTTPSRedirectPolicyRequiresHTTPS() { + let secureRequest = URLRequest( + url: URL(string: "https://cdn.example.com/ddi.zip")! + ) + let insecureRequest = URLRequest( + url: URL(string: "http://cdn.example.com/ddi.zip")! + ) + + XCTAssertNotNil( + HTTPSOnlyURLSessionDelegate + .approvedRedirectRequest(secureRequest) + ) + XCTAssertNil( + HTTPSOnlyURLSessionDelegate + .approvedRedirectRequest(insecureRequest) + ) + } +} + +private final class RecordingDeveloperDiskImageArchiveDownloader: + DeveloperDiskImageArchiveDownloading, + @unchecked Sendable +{ + private let archiveURL: URL + private let statusCode: Int + private(set) var downloadCount = 0 + + init(archiveURL: URL, statusCode: Int = 200) { + self.archiveURL = archiveURL + self.statusCode = statusCode + } + + func download( + from _: URL, + to destinationURL: URL, + maximumByteCount _: UInt64 + ) async throws -> DeveloperDiskImageArchiveHTTPResponse { + downloadCount += 1 + try FileManager.default.copyItem( + at: archiveURL, + to: destinationURL + ) + let attributes = try FileManager.default.attributesOfItem( + atPath: archiveURL.path + ) + return DeveloperDiskImageArchiveHTTPResponse( + statusCode: statusCode, + expectedContentLength: (attributes[.size] as? NSNumber)? + .int64Value + ) + } +} + +private struct DeveloperDiskImageArchiveFixture { + let directory: URL + let url: URL + + func remove() { + try? FileManager.default.removeItem(at: directory) + } +} + +private func makeArchive( + containing restoreDirectory: URL +) throws -> DeveloperDiskImageArchiveFixture { + let directory = temporaryDirectory() + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let archiveURL = directory.appendingPathComponent("ddi.zip") + let writer = ZipArchiveWriter() + try writer.writeFolderContents( + .init(restoreDirectory.path), + options: [.recursive, .includeContainingFolder] + ) + try Data(writer.finalizeBuffer()).write(to: archiveURL) + return DeveloperDiskImageArchiveFixture( + directory: directory, + url: archiveURL + ) +} + +private func makeArchive( + entries: [(path: String, data: Data)] +) throws -> DeveloperDiskImageArchiveFixture { + let directory = temporaryDirectory() + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let archiveURL = directory.appendingPathComponent("ddi.zip") + let writer = ZipArchiveWriter() + var pathReplacements: [(safe: String, requested: String)] = [] + let requestedPaths = entries.map { $0.path } + for entry in entries { + let safePath = safeArchiveFixturePath( + for: entry.path, + among: requestedPaths + ) + try writer.writeFile( + filename: safePath, + contents: Array(entry.data) + ) + if safePath != entry.path { + pathReplacements.append((safePath, entry.path)) + } + } + var archiveData = Data(try writer.finalizeBuffer()) + for replacement in pathReplacements { + try replaceArchiveFixturePath( + replacement.safe, + with: replacement.requested, + in: &archiveData + ) + } + try archiveData.write(to: archiveURL) + return DeveloperDiskImageArchiveFixture( + directory: directory, + url: archiveURL + ) +} + +private func makeSymbolicLinkArchive( + path: String, + target: String +) throws -> DeveloperDiskImageArchiveFixture { + let directory = temporaryDirectory() + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let archiveURL = directory.appendingPathComponent("ddi.zip") + let writer = ZipArchiveWriter() + try writer.writeFile( + filename: path, + contents: Array(target.utf8), + metadata: .init( + externalAttributes: .unix([ + .isSymbolicLink, + .permissions([.ownerReadWrite]), + ]) + ) + ) + try Data(writer.finalizeBuffer()).write(to: archiveURL) + return DeveloperDiskImageArchiveFixture( + directory: directory, + url: archiveURL + ) +} + +/// Produces a valid placeholder with the same byte count as a malformed path. +private func safeArchiveFixturePath( + for requestedPath: String, + among requestedPaths: [String] +) -> String { + if requestedPath.contains("/./") { + return requestedPath.replacingOccurrences(of: "/./", with: "/x/") + } + if requestedPath.contains("//") { + return requestedPath.replacingOccurrences(of: "//", with: "/x") + } + if requestedPaths.contains(where: { + $0.hasPrefix("\(requestedPath)/") + }) { + return "\(requestedPath.dropLast())_" + } + return requestedPath +} + +/// Mutates both ZIP filename records because the writer rejects unsafe paths. +private func replaceArchiveFixturePath( + _ safePath: String, + with requestedPath: String, + in archiveData: inout Data +) throws { + let safeBytes = Data(safePath.utf8) + let requestedBytes = Data(requestedPath.utf8) + guard safeBytes.count == requestedBytes.count else { + throw CocoaError(.fileWriteInvalidFileName) + } + + var replacementCount = 0 + var searchStart = archiveData.startIndex + while searchStart < archiveData.endIndex, + let range = archiveData.range( + of: safeBytes, + in: searchStart ..< archiveData.endIndex + ) + { + archiveData.replaceSubrange(range, with: requestedBytes) + replacementCount += 1 + searchStart = range.upperBound + } + guard replacementCount >= 2 else { + throw CocoaError(.fileReadCorruptFile) + } +} + +private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) +} diff --git a/Tests/RorkDeviceTests/FakeConnection.swift b/Tests/RorkDeviceTests/FakeConnection.swift index df7802d..bc01bea 100644 --- a/Tests/RorkDeviceTests/FakeConnection.swift +++ b/Tests/RorkDeviceTests/FakeConnection.swift @@ -7,14 +7,19 @@ final class FakeConnection: DeviceConnection { /// Simulates a connection loss after a protocol request has been sent. private let receiveFailureAfterSendCount: Int? + private let receiveFailure: RorkDeviceError private(set) var isClosed = false init( inbound: Data = Data(), - receiveFailureAfterSendCount: Int? = nil + receiveFailureAfterSendCount: Int? = nil, + receiveFailure: RorkDeviceError = .transport( + "Injected receive failure." + ) ) { self.inbound = inbound self.receiveFailureAfterSendCount = receiveFailureAfterSendCount + self.receiveFailure = receiveFailure } func enqueue(_ data: Data) { @@ -34,9 +39,7 @@ final class FakeConnection: DeviceConnection { } if let receiveFailureAfterSendCount, sent.count >= receiveFailureAfterSendCount { - throw RorkDeviceError.transport( - "Injected receive failure." - ) + throw receiveFailure } guard inbound.count >= count else { throw RorkDeviceError.transport("Fake connection underflow.") diff --git a/Tests/RorkDeviceTests/MobileImageMounterClientTests.swift b/Tests/RorkDeviceTests/MobileImageMounterClientTests.swift new file mode 100644 index 0000000..598868d --- /dev/null +++ b/Tests/RorkDeviceTests/MobileImageMounterClientTests.swift @@ -0,0 +1,391 @@ +import Foundation +import XCTest + +@testable import RorkDevice + +final class MobileImageMounterClientTests: XCTestCase { + func testMountReusesDevicePersonalizationManifest() async throws { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let ticket = Data([0xAA, 0xBB]) + let connection = FakeConnection( + inbound: try framedMessages([ + [ + "ImagePresent": false, + "Status": "Complete", + ], + personalizationIdentifiersResponse(), + [ + "ImageSignature": ticket, + "Status": "Complete", + ], + ["Status": "ReceiveBytesAck"], + ["Status": "Complete"], + ["Status": "Complete"], + ]) + ) + let connections = ImageMounterConnectionQueue([connection]) + let requester = RecordingDeveloperDiskImageTicketRequester( + ticket: Data([0xCC]) + ) + let mounter = PersonalizedDeveloperDiskImageMounter( + openConnection: { + try await connections.open() + }, + ticketRequester: requester + ) + + let result = try await mounter.mount( + PersonalizedDeveloperDiskImage( + contentsOf: fixture.restoreDirectory + ), + ecid: 123 + ) + + XCTAssertEqual( + result, + .mounted(ticketSource: .deviceManifest) + ) + XCTAssertEqual(result.status, .mounted) + XCTAssertEqual(result.ticketSource, .deviceManifest) + XCTAssertTrue(result.requiresTunnelRestart) + XCTAssertEqual(requester.requestCount, 0) + XCTAssertTrue(connection.isClosed) + XCTAssertEqual( + try command(in: connection.sent[0])["Command"] as? String, + "LookupImage" + ) + let manifestRequest = try command(in: connection.sent[2]) + XCTAssertEqual( + manifestRequest["Command"] as? String, + "QueryPersonalizationManifest" + ) + XCTAssertEqual( + (manifestRequest["ImageSignature"] as? Data)?.count, + 48 + ) + let receiveBytes = try command(in: connection.sent[3]) + XCTAssertEqual( + receiveBytes["ImageSignature"] as? Data, + ticket + ) + XCTAssertEqual( + connection.sent[4], + Data("developer-image".utf8) + ) + let mount = try command(in: connection.sent[5]) + XCTAssertEqual(mount["Command"] as? String, "MountImage") + XCTAssertEqual(mount["ImageSignature"] as? Data, ticket) + XCTAssertEqual( + mount["ImageTrustCache"] as? Data, + Data("trust-cache".utf8) + ) + XCTAssertEqual( + try command(in: connection.sent[6])["Command"] as? String, + "Hangup" + ) + } + + func testMountReopensServiceAfterMissingManifestAndRequestsTicket() + async throws + { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let initialConnection = FakeConnection( + inbound: try framedMessages([ + [ + "ImagePresent": false, + "Status": "Complete", + ], + personalizationIdentifiersResponse(), + ]), + receiveFailureAfterSendCount: 3, + receiveFailure: .transport("Connection closed.") + ) + let replacementConnection = FakeConnection( + inbound: try framedMessages([ + [ + "PersonalizationNonce": Data([0x10, 0x20]), + "Status": "Complete", + ], + ["Status": "ReceiveBytesAck"], + ["Status": "Complete"], + ["Status": "Complete"], + ]) + ) + let connections = ImageMounterConnectionQueue([ + initialConnection, + replacementConnection, + ]) + let ticket = Data([0xDE, 0xAD]) + let requester = RecordingDeveloperDiskImageTicketRequester( + ticket: ticket + ) + let mounter = PersonalizedDeveloperDiskImageMounter( + openConnection: { + try await connections.open() + }, + ticketRequester: requester + ) + + let result = try await mounter.mount( + PersonalizedDeveloperDiskImage( + contentsOf: fixture.restoreDirectory + ), + ecid: 123 + ) + + XCTAssertEqual( + result, + .mounted(ticketSource: .appleTSS) + ) + XCTAssertEqual(requester.requestCount, 1) + XCTAssertEqual(requester.nonce, Data([0x10, 0x20])) + XCTAssertEqual(requester.ecid, 123) + XCTAssertTrue(initialConnection.isClosed) + XCTAssertTrue(replacementConnection.isClosed) + XCTAssertEqual( + try command( + in: replacementConnection.sent[0] + )["Command"] as? String, + "QueryNonce" + ) + XCTAssertEqual( + try command( + in: replacementConnection.sent[1] + )["ImageSignature"] as? Data, + ticket + ) + } + + func testMountReturnsWithoutUploadWhenImageIsAlreadyMounted() + async throws + { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let connection = FakeConnection( + inbound: try framedMessages([ + [ + "ImageSignature": [Data([0x01])], + "Status": "Complete", + ], + ]) + ) + let connections = ImageMounterConnectionQueue([connection]) + let requester = RecordingDeveloperDiskImageTicketRequester( + ticket: Data([0x02]) + ) + let mounter = PersonalizedDeveloperDiskImageMounter( + openConnection: { + try await connections.open() + }, + ticketRequester: requester + ) + + let result = try await mounter.mount( + PersonalizedDeveloperDiskImage( + contentsOf: fixture.restoreDirectory + ), + ecid: 123 + ) + + XCTAssertEqual( + result, + .alreadyMounted + ) + XCTAssertEqual(connection.sent.count, 1) + XCTAssertEqual(requester.requestCount, 0) + XCTAssertEqual(result.status, .alreadyMounted) + XCTAssertNil(result.ticketSource) + XCTAssertFalse(result.requiresTunnelRestart) + } + + func testMountSurfacesPersonalizationManifestDeviceError() + async throws + { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let connection = FakeConnection( + inbound: try framedMessages([ + [ + "ImagePresent": false, + "Status": "Complete", + ], + personalizationIdentifiersResponse(), + [ + "Error": "PersonalizationFailed", + "DetailedError": "device rejected manifest query", + ], + ]) + ) + let connections = ImageMounterConnectionQueue([connection]) + let requester = RecordingDeveloperDiskImageTicketRequester( + ticket: Data([0xCC]) + ) + let mounter = PersonalizedDeveloperDiskImageMounter( + openConnection: { + try await connections.open() + }, + ticketRequester: requester + ) + + await XCTAssertThrowsErrorAsync({ + try await mounter.mount( + PersonalizedDeveloperDiskImage( + contentsOf: fixture.restoreDirectory + ), + ecid: 123 + ) + }) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .lockdown( + "QueryPersonalizationManifest failed: PersonalizationFailed: device rejected manifest query" + ) + ) + } + XCTAssertEqual(requester.requestCount, 0) + XCTAssertTrue(connection.isClosed) + } + + func testMountDoesNotTreatTransportFailureAsMissingManifest() + async throws + { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let connection = FakeConnection( + inbound: try framedMessages([ + [ + "ImagePresent": false, + "Status": "Complete", + ], + personalizationIdentifiersResponse(), + ]), + receiveFailureAfterSendCount: 3 + ) + let connections = ImageMounterConnectionQueue([connection]) + let requester = RecordingDeveloperDiskImageTicketRequester( + ticket: Data([0xCC]) + ) + let mounter = PersonalizedDeveloperDiskImageMounter( + openConnection: { + try await connections.open() + }, + ticketRequester: requester + ) + + await XCTAssertThrowsErrorAsync({ + try await mounter.mount( + PersonalizedDeveloperDiskImage( + contentsOf: fixture.restoreDirectory + ), + ecid: 123 + ) + }) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .transport("Injected receive failure.") + ) + } + XCTAssertEqual(requester.requestCount, 0) + XCTAssertTrue(connection.isClosed) + } +} + +private final class ImageMounterConnectionQueue { + private var connections: [FakeConnection] + + init(_ connections: [FakeConnection]) { + self.connections = connections + } + + func open() async throws -> DeviceConnection { + guard !connections.isEmpty else { + throw RorkDeviceError.transport( + "No fake image-mounter connection remains." + ) + } + return connections.removeFirst() + } +} + +private final class RecordingDeveloperDiskImageTicketRequester: + DeveloperDiskImageTicketRequesting, + @unchecked Sendable +{ + private let ticketValue: Data + private(set) var requestCount = 0 + private(set) var nonce: Data? + private(set) var ecid: UInt64? + + init(ticket: Data) { + ticketValue = ticket + } + + func ticket( + for _: DeveloperDiskImageBuildIdentity, + identifiers _: PersonalizationIdentifiers, + nonce: Data, + ecid: UInt64 + ) async throws -> Data { + requestCount += 1 + self.nonce = nonce + self.ecid = ecid + return ticketValue + } +} + +private func personalizationIdentifiersResponse() -> [String: Any] { + [ + "PersonalizationIdentifiers": [ + "BoardId": 12, + "ChipID": 0x8150, + "SecurityDomain": 1, + "Ap,ProductType": "iPhone18,1", + ], + "Status": "Complete", + ] +} + +private func framedMessages( + _ messages: [[String: Any]] +) throws -> Data { + try messages.reduce(into: Data()) { + $0.append(try PropertyListMessageFramer.encode($1)) + } +} + +private func command(in data: Data) throws -> [String: Any] { + guard data.count >= 4 else { + throw RorkDeviceError.protocolViolation( + "Captured data is not a framed property list." + ) + } + return try XCTUnwrap( + PropertyListSerialization.propertyList( + from: data.dropFirst(4), + options: [], + format: nil + ) as? [String: Any] + ) +} diff --git a/Tests/RorkDeviceTests/PersonalizedDeveloperDiskImageTests.swift b/Tests/RorkDeviceTests/PersonalizedDeveloperDiskImageTests.swift new file mode 100644 index 0000000..a7fe031 --- /dev/null +++ b/Tests/RorkDeviceTests/PersonalizedDeveloperDiskImageTests.swift @@ -0,0 +1,213 @@ +import Crypto +import Foundation +import XCTest + +@testable import RorkDevice + +final class PersonalizedDeveloperDiskImageTests: XCTestCase { + func testPayloadSelectsMatchingHardwareIdentityAndVerifiesFiles() throws { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + let image = try PersonalizedDeveloperDiskImage( + contentsOf: fixture.restoreDirectory + ) + + let payload = try image.payload( + matching: PersonalizationIdentifiers( + boardID: 12, + chipID: 0x8150, + securityDomain: 1, + additionalTSSParameters: [:] + ) + ) + + XCTAssertEqual(payload.imageURL.lastPathComponent, "DeveloperDiskImage.dmg") + XCTAssertEqual(payload.trustCacheURL.lastPathComponent, "DeveloperDiskImage.dmg.trustcache") + XCTAssertEqual(payload.buildIdentity.boardID, 12) + XCTAssertEqual(payload.buildIdentity.chipID, 0x8150) + XCTAssertEqual(payload.buildIdentity.securityDomain, 1) + } + + func testPayloadRejectsIdentityForAnotherSecurityDomain() throws { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x02" + ) + defer { fixture.remove() } + let image = try PersonalizedDeveloperDiskImage( + contentsOf: fixture.restoreDirectory + ) + + XCTAssertThrowsError( + try image.payload( + matching: PersonalizationIdentifiers( + boardID: 12, + chipID: 0x8150, + securityDomain: 1, + additionalTSSParameters: [:] + ) + ) + ) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .invalidInput( + "The Developer Disk Image does not support board 0xC, chip 0x8150, security domain 1." + ) + ) + } + } + + func testPayloadRejectsManifestPathOutsideRestoreDirectory() throws { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01", + imagePath: "../DeveloperDiskImage.dmg" + ) + defer { fixture.remove() } + let image = try PersonalizedDeveloperDiskImage( + contentsOf: fixture.restoreDirectory + ) + + XCTAssertThrowsError( + try image.payload( + matching: PersonalizationIdentifiers( + boardID: 12, + chipID: 0x8150, + securityDomain: 1, + additionalTSSParameters: [:] + ) + ) + ) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .invalidInput( + "Developer Disk Image manifest path escapes the Restore directory: ../DeveloperDiskImage.dmg" + ) + ) + } + } + + func testPayloadRejectsFileWhoseSHA384DoesNotMatchManifest() throws { + let fixture = try makeImageFixture( + boardID: "0x0C", + chipID: "0x8150", + securityDomain: "0x01" + ) + defer { fixture.remove() } + try Data("tampered".utf8).write( + to: fixture.restoreDirectory.appendingPathComponent( + "DeveloperDiskImage.dmg" + ) + ) + let image = try PersonalizedDeveloperDiskImage( + contentsOf: fixture.restoreDirectory + ) + + XCTAssertThrowsError( + try image.payload( + matching: PersonalizationIdentifiers( + boardID: 12, + chipID: 0x8150, + securityDomain: 1, + additionalTSSParameters: [:] + ) + ) + ) { error in + XCTAssertEqual( + error as? RorkDeviceError, + .invalidInput( + "Developer Disk Image digest does not match BuildManifest.plist." + ) + ) + } + } +} + +struct ImageFixture { + let directory: URL + let restoreDirectory: URL + + func remove() { + try? FileManager.default.removeItem(at: directory) + } +} + +func makeImageFixture( + boardID: String, + chipID: String, + securityDomain: String, + imagePath: String = "DeveloperDiskImage.dmg" +) throws -> ImageFixture { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let restoreDirectory = directory.appendingPathComponent( + "Restore", + isDirectory: true + ) + let firmwareDirectory = restoreDirectory.appendingPathComponent( + "Firmware", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: firmwareDirectory, + withIntermediateDirectories: true + ) + let imageData = Data("developer-image".utf8) + let trustCacheData = Data("trust-cache".utf8) + try imageData.write( + to: restoreDirectory.appendingPathComponent( + "DeveloperDiskImage.dmg" + ) + ) + try trustCacheData.write( + to: firmwareDirectory.appendingPathComponent( + "DeveloperDiskImage.dmg.trustcache" + ) + ) + let manifest: [String: Any] = [ + "BuildIdentities": [ + [ + "ApBoardID": boardID, + "ApChipID": chipID, + "ApSecurityDomain": securityDomain, + "Manifest": [ + "PersonalizedDMG": [ + "Digest": Data(SHA384.hash(data: imageData)), + "Info": [ + "Path": imagePath, + ], + "Name": "DeveloperDiskImage", + "Trusted": true, + ], + "LoadableTrustCache": [ + "Digest": Data(SHA384.hash(data: trustCacheData)), + "Info": [ + "Path": "Firmware/DeveloperDiskImage.dmg.trustcache", + ], + "Trusted": true, + ], + ], + ], + ], + ] + let manifestData = try PropertyListSerialization.data( + fromPropertyList: manifest, + format: .xml, + options: 0 + ) + try manifestData.write( + to: restoreDirectory.appendingPathComponent("BuildManifest.plist") + ) + return ImageFixture( + directory: directory, + restoreDirectory: restoreDirectory + ) +}