fix(ddi): mount verified generic Cryptex images on iOS 27

This commit is contained in:
zeuzmakessoftware 2026-09-20 22:55:30 -07:00
parent 265ceb1d99
commit a4722251f2
4 changed files with 155 additions and 0 deletions

12
Package.swift Normal file
View file

@ -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")
]
)

View file

@ -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<InstalledCryptexC>?
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(

View file

@ -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."
}
}
}
}

View file

@ -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: "<string>Image.dmg</string>", with: "<string>../Image.dmg</string>")
try Data(xml.utf8).write(to: url)
#expect(throws: (any Error).self) { try CryptexDDI.validate(in: directory) }
}
}
}