diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..53baf05 --- /dev/null +++ b/Package.swift @@ -0,0 +1,12 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// Exercise DDI storage and update behavior without linking the device-only idevice library. +let package = Package( + name: "StikDebugDDI", + platforms: [.macOS(.v14)], + targets: [ + .target(name: "DDIKit", path: "StikDebug/Services/DeveloperDiskImages"), + .testTarget(name: "DDIKitTests", dependencies: ["DDIKit"], path: "Tests/DDI") + ] +) diff --git a/StikDebug/Device/IdeviceFFIBridge.swift b/StikDebug/Device/IdeviceFFIBridge.swift index 6871c79..08a46f1 100644 --- a/StikDebug/Device/IdeviceFFIBridge.swift +++ b/StikDebug/Device/IdeviceFFIBridge.swift @@ -292,6 +292,11 @@ extension JITEnableContext { } func mountPersonalDDI(withImagePath imagePath: String, trustcachePath: String, manifestPath: String) throws { + if #available(iOS 27, *), + let directory = Bundle.main.url(forResource: "BundledCryptexDDI", withExtension: nil), + try mountCryptexDDIIfAvailable(in: directory) { + return + } let imageData = try IdeviceBridge.mappedFileData(atPath: imagePath, description: "developer disk image") let trustcacheData = try IdeviceBridge.mappedFileData(atPath: trustcachePath, description: "developer disk image trust cache") let manifestData = try IdeviceBridge.mappedFileData(atPath: manifestPath, description: "developer disk image manifest") @@ -352,6 +357,37 @@ extension JITEnableContext { } } + /// Use Apple's generic Cryptex identity on iOS 27. New hardware can lack a + /// legacy personalized identity even in the newest Xcode developer image. + private func mountCryptexDDIIfAvailable(in directory: URL) throws -> Bool { + try CryptexDDI.validate(in: directory) + return try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in + var available = false + if let error = rsd_service_available(handshake, "com.apple.security.cryptexd.remote", &available) { + throw IdeviceBridge.consumeFFIError(error, fallback: "Failed to query Cryptex mounting support") + } + guard available else { return false } + + var assets: OpaquePointer? + if let error = cryptex1_assets_load(directory.path, &assets) { + throw IdeviceBridge.consumeFFIError(error, fallback: "Failed to load the verified Cryptex DDI") + } + guard let assets else { throw IdeviceBridge.makeError(message: "Cryptex assets were not created") } + defer { cryptex1_assets_free(assets) } + + var installed: UnsafeMutablePointer? + defer { cryptexd_free_installed_cryptex(installed) } + if let error = cryptexd_install_ddi(adapter, handshake, assets, &installed) { + throw IdeviceBridge.consumeFFIError(error, fallback: "Failed to mount the Cryptex DDI") + } + guard let installed, let identifier = installed.pointee.identifier, + String(cString: identifier) == "com.apple.MobileAsset.DDI" else { + throw IdeviceBridge.makeError(message: "Cryptex install did not confirm a mounted developer image") + } + return true + } + } + func fetchAllProfiles() throws -> [Data] { try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in try IdeviceBridge.withConnectedClient( diff --git a/StikDebug/Services/DeveloperDiskImages/CryptexDDI.swift b/StikDebug/Services/DeveloperDiskImages/CryptexDDI.swift new file mode 100644 index 0000000..b6e37d6 --- /dev/null +++ b/StikDebug/Services/DeveloperDiskImages/CryptexDDI.swift @@ -0,0 +1,54 @@ +import CryptoKit +import Foundation + +/// Cryptex DDIs use a generic identity and four signed payloads, rather than an +/// ApChipID/ApBoardID identity. Never mix these with the personalized image files. +enum CryptexDDI { + static let payloads = [ + "Cryptex1,GenericDmg": "Image.dmg", + "Cryptex1,GenericTrustCache": "Image.dmg.trustcache", + "Cryptex1,CryptexInfoPlist": "Image.dmg.cryptex_info", + "Cryptex1,GenericVolume": "Image.dmg.root_hash" + ] + + static func validate(in directory: URL) throws { + let data = try Data(contentsOf: directory.appendingPathComponent("BuildManifest.plist")) + guard let manifest = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let identities = manifest["BuildIdentities"] as? [[String: Any]], + let identity = identities.first(where: { + (($0["Info"] as? [String: Any])?["Variant"] as? String)? + .hasSuffix("Developer Disk Image Cryptex") == true + }), + let entries = identity["Manifest"] as? [String: [String: Any]] else { + throw ValidationError.invalidManifest + } + // The native loader follows manifest paths. Require the normalized names + // we verified so it cannot read an unverified file or follow a symlink out. + let root = directory.resolvingSymlinksInPath().standardizedFileURL.path + "/" + for (key, name) in payloads { + guard let entry = entries[key], + (entry["Info"] as? [String: Any])?["Path"] as? String == name, + let digest = entry["Digest"] as? Data else { + throw ValidationError.invalidManifest + } + let url = directory.appendingPathComponent(name).resolvingSymlinksInPath().standardizedFileURL + guard url.path.hasPrefix(root) else { throw ValidationError.invalidManifest } + let bytes = try Data(contentsOf: url, options: .mappedIfSafe) + guard !bytes.isEmpty, Data(SHA384.hash(data: bytes)) == digest else { + throw ValidationError.invalidPayload(name) + } + } + } + + enum ValidationError: LocalizedError { + case invalidManifest + case invalidPayload(String) + + var errorDescription: String? { + switch self { + case .invalidManifest: return "The bundled Cryptex DDI manifest is invalid. Reinstall a complete build of this fork." + case .invalidPayload(let name): return "The bundled Cryptex DDI file \(name) failed verification. Reinstall a complete build of this fork." + } + } + } +} diff --git a/Tests/DDI/CryptexDDITests.swift b/Tests/DDI/CryptexDDITests.swift new file mode 100644 index 0000000..fb6f3b2 --- /dev/null +++ b/Tests/DDI/CryptexDDITests.swift @@ -0,0 +1,53 @@ +import CryptoKit +import Foundation +import Testing +@testable import DDIKit + +struct CryptexDDITests { + private func fixture(_ body: (URL) throws -> Void) throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + var entries: [String: Any] = [:] + for (key, name) in CryptexDDI.payloads { + let data = Data(name.utf8) + try data.write(to: directory.appendingPathComponent(name)) + entries[key] = ["Digest": Data(SHA384.hash(data: data)), "Info": ["Path": name]] + } + let manifest: [String: Any] = ["BuildIdentities": [[ + "Info": ["Variant": "iOS Developer Disk Image Cryptex"], "Manifest": entries + ]]] + try PropertyListSerialization.data(fromPropertyList: manifest, format: .xml, options: 0) + .write(to: directory.appendingPathComponent("BuildManifest.plist")) + try body(directory) + } + + @Test func genericIdentityDoesNotRequireLegacyHardwareIDs() throws { + try fixture { try CryptexDDI.validate(in: $0) } + } + + @Test(arguments: Array(CryptexDDI.payloads.values)) + func everyPayloadMustMatchItsSignedDigest(_ name: String) throws { + try fixture { directory in + try Data("corrupt".utf8).write(to: directory.appendingPathComponent(name)) + #expect(throws: (any Error).self) { try CryptexDDI.validate(in: directory) } + } + } + + @Test func missingRootHashIsRejected() throws { + try fixture { directory in + try FileManager.default.removeItem(at: directory.appendingPathComponent("Image.dmg.root_hash")) + #expect(throws: (any Error).self) { try CryptexDDI.validate(in: directory) } + } + } + + @Test func nativeLoaderCannotFollowUnverifiedManifestPaths() throws { + try fixture { directory in + let url = directory.appendingPathComponent("BuildManifest.plist") + let xml = try String(contentsOf: url, encoding: .utf8) + .replacingOccurrences(of: "Image.dmg", with: "../Image.dmg") + try Data(xml.utf8).write(to: url) + #expect(throws: (any Error).self) { try CryptexDDI.validate(in: directory) } + } + } +}