Skip to content

Commit 3fa87fb

Browse files
committed
fix(ios): harden third-party app signing
1 parent 50fc006 commit 3fa87fb

13 files changed

Lines changed: 138 additions & 14 deletions

File tree

.github/workflows/release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ jobs:
106106
-only-testing:AltTests/AltTests/testSourceID
107107
-only-testing:AltTests/AltTests/testAltForgeSourceDeepLink
108108
-only-testing:AltTests/AltTests/testALTApplicationIgnoresMalformedOptionalMetadata
109+
-only-testing:AltTests/AltTests/testUnsupportedAppleWatchBundleIsRemovedBeforeSigning
110+
-only-testing:AltTests/AltTests/testSigningDiagnosticDetailIsRelativeAndBounded
109111
-only-testing:AltTests/AltTests/testThemePreferenceDefaultsAndRoundTrips
110112
111113
- name: Build unsigned iOS application

AltStore/Managing Apps/AppManager.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,15 @@ private final class PendingAppOperationStore
104104
var events = records[index].events ?? []
105105
guard events.last?.stage != stage || events.last?.detail != boundedDetail else { return }
106106

107-
events.append(PendingAppOperationEvent(date: Date(), stage: stage, detail: boundedDetail))
107+
let event = PendingAppOperationEvent(date: Date(), stage: stage, detail: boundedDetail)
108+
if stage == .signingApp, events.last?.stage == .signingApp
109+
{
110+
events[events.count - 1] = event
111+
}
112+
else
113+
{
114+
events.append(event)
115+
}
108116
records[index].events = Array(events.suffix(PendingAppOperation.maximumEventCount))
109117
self.save(records)
110118
}

AltStore/Operations/ResignAppOperation.swift

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,46 @@ import Roxas
1212
import AltStoreCore
1313
import AltSign
1414

15+
func removeUnsupportedAppleWatchBundle(from appBundleURL: URL, fileManager: FileManager = .default) throws -> Bool
16+
{
17+
let watchURL = appBundleURL.appendingPathComponent("Watch", isDirectory: true)
18+
guard fileManager.fileExists(atPath: watchURL.path) else { return false }
19+
20+
try fileManager.removeItem(at: watchURL)
21+
return true
22+
}
23+
24+
func sanitizedSigningDiagnosticDetail(_ rawValue: String) -> String?
25+
{
26+
var value = rawValue
27+
.replacingOccurrences(of: "\\", with: "/")
28+
.unicodeScalars
29+
.filter { !CharacterSet.controlCharacters.contains($0) }
30+
.map(String.init)
31+
.joined()
32+
33+
if value == "*"
34+
{
35+
return NSLocalizedString("Main App Bundle", comment: "Signing diagnostic detail")
36+
}
37+
38+
if value.hasSuffix("*")
39+
{
40+
value.removeLast()
41+
}
42+
43+
let components = value
44+
.split(separator: "/", omittingEmptySubsequences: true)
45+
.map(String.init)
46+
.filter { $0 != "." && $0 != ".." }
47+
guard !components.isEmpty else { return nil }
48+
49+
// ldid paths are relative to the app bundle. Keeping only the trailing
50+
// components prevents accidental disclosure if a future caller passes an
51+
// absolute path while retaining the bundle, executable, and architecture.
52+
return components.suffix(4).joined(separator: "/")
53+
}
54+
1555
@objc(ResignAppOperation)
1656
class ResignAppOperation: ResultOperation<ALTApplication>, @unchecked Sendable
1757
{
@@ -158,6 +198,12 @@ private extension ResignAppOperation
158198
{
159199
let appBundleURL = self.context.temporaryDirectory.appendingPathComponent("App.app")
160200
try FileManager.default.copyItem(at: fileURL, to: appBundleURL)
201+
202+
if try removeUnsupportedAppleWatchBundle(from: appBundleURL)
203+
{
204+
self.context.recordDiagnostic(.preparingApp, detail: NSLocalizedString("Removed unsupported Apple Watch components", comment: "App operation diagnostic detail"))
205+
Logger.sideload.notice("Removed unsupported Apple Watch components before signing.")
206+
}
161207

162208
// Become current so we can observe progress from unzipAppBundle().
163209
progress.becomeCurrent(withPendingUnitCount: 1)
@@ -253,7 +299,10 @@ private extension ResignAppOperation
253299
func resignAppBundle(at fileURL: URL, team: ALTTeam, certificate: ALTCertificate, profiles: [ALTProvisioningProfile], completionHandler: @escaping (Result<URL, Error>) -> Void) -> Progress
254300
{
255301
let signer = ALTSigner(team: team, certificate: certificate)
256-
let progress = signer.signApp(at: fileURL, provisioningProfiles: profiles) { (success, error) in
302+
let progress = signer.signApp(at: fileURL, provisioningProfiles: profiles, progressHandler: { rawDetail in
303+
guard let detail = sanitizedSigningDiagnosticDetail(rawDetail) else { return }
304+
self.context.recordDiagnostic(.signingApp, detail: detail)
305+
}) { (success, error) in
257306
do
258307
{
259308
try Result(success, error).get()

AltStore/Resources/Localizable.xcstrings

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,26 @@
361361
}
362362
}
363363
},
364+
"Main App Bundle" : {
365+
"localizations" : {
366+
"zh-Hans" : {
367+
"stringUnit" : {
368+
"state" : "translated",
369+
"value" : "主应用包"
370+
}
371+
}
372+
}
373+
},
374+
"Removed unsupported Apple Watch components" : {
375+
"localizations" : {
376+
"zh-Hans" : {
377+
"stringUnit" : {
378+
"state" : "translated",
379+
"value" : "已移除不受支持的 Apple Watch 组件"
380+
}
381+
}
382+
}
383+
},
364384
"Sending App" : {
365385
"localizations" : {
366386
"zh-Hans" : {

AltTests/AltTests.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,33 @@ final class AltTests: XCTestCase
9393
XCTAssertNil(application.icon)
9494
}
9595

96+
func testUnsupportedAppleWatchBundleIsRemovedBeforeSigning() throws
97+
{
98+
let appURL = FileManager.default.temporaryDirectory
99+
.appendingPathComponent(UUID().uuidString, isDirectory: true)
100+
.appendingPathExtension("app")
101+
defer { try? FileManager.default.removeItem(at: appURL) }
102+
103+
let watchAppURL = appURL
104+
.appendingPathComponent("Watch", isDirectory: true)
105+
.appendingPathComponent("Companion.app", isDirectory: true)
106+
try FileManager.default.createDirectory(at: watchAppURL, withIntermediateDirectories: true)
107+
108+
XCTAssertTrue(try removeUnsupportedAppleWatchBundle(from: appURL))
109+
XCTAssertFalse(FileManager.default.fileExists(atPath: watchAppURL.path))
110+
XCTAssertFalse(try removeUnsupportedAppleWatchBundle(from: appURL))
111+
}
112+
113+
func testSigningDiagnosticDetailIsRelativeAndBounded()
114+
{
115+
let rawValue = "/private/var/mobile/Payload/WeChat.app/Frameworks/Example.framework/Example (arm64)\n"
116+
let detail = sanitizedSigningDiagnosticDetail(rawValue)
117+
118+
XCTAssertEqual(detail, "WeChat.app/Frameworks/Example.framework/Example (arm64)")
119+
XCTAssertFalse(detail?.contains("/private/var/mobile") == true)
120+
XCTAssertEqual(sanitizedSigningDiagnosticDetail("*"), NSLocalizedString("Main App Bundle", comment: "Signing diagnostic detail"))
121+
}
122+
96123
func testThemePreferenceDefaultsAndRoundTrips()
97124
{
98125
let suiteName = "com.legeling.AltForgeTests.Theme.\(UUID().uuidString)"

Scripts/test_repository_contract.rb

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ def png_dimensions(root, path)
105105
assert(ldid_source.include?("#define CPU_TYPE_ARM64_32"), "ldid must recognize Apple Watch arm64_32 Mach-O binaries")
106106
assert(ldid_source.include?('arch = "arm64_32"'), "ldid signing progress must name the arm64_32 architecture without constructing a string from NULL")
107107
assert(ldid_source.include?("unsupported CPU type:"), "ldid must reject unknown CPU types with a catchable error instead of crashing")
108+
alt_signer = read(root, "Dependencies/AltSign/AltSign/Signing/ALTSigner.mm")
109+
assert(!alt_signer.include?("return entitlements.UTF8String;"), "AltSign must not construct std::string from nullable Objective-C entitlement bytes")
110+
assert(alt_signer.include?("embedded bundle is missing prepared entitlements or a provisioning profile"), "AltSign must turn missing embedded-bundle entitlements into a catchable error")
111+
assert(alt_signer.include?("progressHandler(detail)"), "AltSign must expose bounded bundle and Mach-O signing checkpoints")
108112
release_workflow = read(root, ".github/workflows/release.yml")
109113
assert(release_workflow.include?("bash Scripts/test_ldid_architecture_compatibility.sh"), "Apple release CI must exercise ldid architecture compatibility")
110114

@@ -157,6 +161,7 @@ def png_dimensions(root, path)
157161
assert(ios_app_manager.include?("recoverInterruptedOperations"), "interrupted iOS operations must become visible after relaunch")
158162
assert(ios_app_manager.include?("PendingAppOperations.json"), "pending iOS operations must use an atomic on-disk journal")
159163
assert(ios_app_manager.include?("try data.write(to: self.fileURL, options: .atomic)"), "pending iOS operation journal writes must be atomic")
164+
assert(ios_app_manager.include?("if stage == .signingApp, events.last?.stage == .signingApp"), "signing checkpoints must replace the latest signing event instead of evicting the bounded stage history")
160165
assert(ios_app_manager.include?("recoverUnexpectedTermination"), "unexpected foreground termination must become visible after relaunch")
161166
assert(ios_app_manager.include?("CurrentSession.json") && ios_app_manager.include?("InterruptedSession.json"), "app lifecycle recovery must retain bounded current and interrupted session records")
162167
assert(ios_app_manager.include?("guard didSave else { return }"), "interrupted operation records must remain pending until the recovery log saves")
@@ -174,6 +179,9 @@ def png_dimensions(root, path)
174179
assert(my_apps_update&.include?("cellForItem(at: indexPath) as? NoUpdatesCollectionViewCell"), "My Apps must update the already-visible no-updates cell without mutating collection structure")
175180
verify_app = read(root, "AltStore/Operations/VerifyAppOperation.swift")
176181
assert(verify_app.include?("throw self.context.error ?? OperationError.invalidApp()"), "a missing prepared app must surface as a handled invalid-app error")
182+
resign_app = read(root, "AltStore/Operations/ResignAppOperation.swift")
183+
assert(resign_app.include?("removeUnsupportedAppleWatchBundle(from: appBundleURL)"), "unsupported Apple Watch companion bundles must be removed before iPhone app signing")
184+
assert(resign_app.include?("sanitizedSigningDiagnosticDetail"), "signing checkpoints must be sanitized before entering persistent diagnostics")
177185

178186
ios_operation_contexts = read(root, "AltStore/Operations/OperationContexts.swift")
179187
%w[findingServer authenticating preparingApp verifyingApp preparingProfiles signingApp sendingApp installingApp refreshingApp].each do |stage|
@@ -183,10 +191,11 @@ def png_dimensions(root, path)
183191
assert(ios_error_log.include?('NSLocalizedString("Copy Diagnostic Report"'), "error log must expose the bounded diagnostic report action")
184192
assert(ios_error_log.include?("ALTDiagnosticTraceErrorKey"), "copied error reports must include the operation trace")
185193
diagnostic_detail_calls = Dir.glob(File.join(root, "AltStore/**/*.swift")).flat_map { |path| File.readlines(path).grep(/recordDiagnostic\(\..*detail:/) }
186-
assert(diagnostic_detail_calls.all? { |line| line.include?("localizedDiagnosticName") || line.include?("authenticationDiagnosticDetail") }, "diagnostic details must remain on the connection/team-category allowlist")
194+
allowed_diagnostic_details = ["localizedDiagnosticName", "authenticationDiagnosticDetail", "recordDiagnostic(.signingApp, detail: detail)", "Removed unsupported Apple Watch components"]
195+
assert(diagnostic_detail_calls.all? { |line| allowed_diagnostic_details.any? { |value| line.include?(value) } }, "diagnostic details must remain on the connection/team/signing allowlist")
187196

188197
ios_strings = JSON.parse(read(root, "AltStore/Resources/Localizable.xcstrings")).fetch("strings")
189-
["Authentication Ready", "Authenticating Apple ID", "Diagnostic ID", "Failure Stage", "Operation Trace", "Copy Diagnostic Report", "AltForge closed unexpectedly while it was active.", "The installation ended before AltForge received a result.", "Theme Color", "Forge Red", "Ocean Blue", "Indigo", "Rose", "Selected"].each do |key|
198+
["Authentication Ready", "Authenticating Apple ID", "Diagnostic ID", "Failure Stage", "Operation Trace", "Copy Diagnostic Report", "AltForge closed unexpectedly while it was active.", "The installation ended before AltForge received a result.", "Main App Bundle", "Removed unsupported Apple Watch components", "Theme Color", "Forge Red", "Ocean Blue", "Indigo", "Rose", "Selected"].each do |key|
190199
assert(ios_strings.dig(key, "localizations", "zh-Hans", "stringUnit", "value"), "missing Simplified Chinese diagnostic string: #{key}")
191200
end
192201

0 commit comments

Comments
 (0)