From bc209f6112809cd6034bf8a2efb405ce388b9581 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Sat, 26 Dec 2020 16:17:25 -0700 Subject: [PATCH] Add privileged helper that runs xcode-select --- DECISIONS.md | 20 ++ HelperXPCShared/HelperXPCShared.swift | 11 + README.md | 2 + Scripts/uninstall_privileged_helper.sh | 18 ++ Xcodes.xcodeproj/project.pbxproj | 216 +++++++++++++++++- Xcodes/Backend/AppState.swift | 50 +++- Xcodes/Backend/HelperClient.swift | 106 +++++++++ Xcodes/Backend/HelperInstaller.swift | 64 ++++++ Xcodes/Resources/Info.plist | 9 +- Xcodes/SettingsView.swift | 22 ++ .../AuditTokenHack.h | 37 +++ .../AuditTokenHack.m | 33 +++ .../ConnectionVerifier.swift | 144 ++++++++++++ .../Info.plist | 18 ++ .../XPCDelegate.swift | 85 +++++++ ...pencils.XcodesApp.Helper-Bridging-Header.h | 1 + ...dpencils.XcodesApp.HelperTest.entitlements | 5 + .../launchd.plist | 13 ++ .../main.swift | 9 + 19 files changed, 860 insertions(+), 3 deletions(-) create mode 100644 HelperXPCShared/HelperXPCShared.swift create mode 100755 Scripts/uninstall_privileged_helper.sh create mode 100644 Xcodes/Backend/HelperClient.swift create mode 100644 Xcodes/Backend/HelperInstaller.swift create mode 100644 com.robotsandpencils.XcodesApp.Helper/AuditTokenHack.h create mode 100644 com.robotsandpencils.XcodesApp.Helper/AuditTokenHack.m create mode 100644 com.robotsandpencils.XcodesApp.Helper/ConnectionVerifier.swift create mode 100644 com.robotsandpencils.XcodesApp.Helper/Info.plist create mode 100644 com.robotsandpencils.XcodesApp.Helper/XPCDelegate.swift create mode 100644 com.robotsandpencils.XcodesApp.Helper/com.robotsandpencils.XcodesApp.Helper-Bridging-Header.h create mode 100644 com.robotsandpencils.XcodesApp.Helper/com.robotsandpencils.XcodesApp.HelperTest.entitlements create mode 100644 com.robotsandpencils.XcodesApp.Helper/launchd.plist create mode 100644 com.robotsandpencils.XcodesApp.Helper/main.swift diff --git a/DECISIONS.md b/DECISIONS.md index 3bbae44..5671e1a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -29,3 +29,23 @@ xcodes used Point Free's Environment type, and I'm happy with how that turned ou ## State Management While I'm curious and eager to try Point Free's [Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture), I'm going to avoid it at first in favour of a simpler AppState ObservableObject. My motivation for this is to try to have something more familiar to a contributor that was also new to SwiftUI, so that the codebase doesn't have too many new or unfamiliar things. If we run into performance or correctness issues in the future I think TCA should be a candidate to reconsider. + +## Privilege Escalation + +Unlike [xcodes](https://github.com/RobotsAndPencils/xcodes/blob/master/DECISIONS.md#privilege-escalation), there is a better option than running sudo in a Process when we need to escalate privileges in Xcodes.app, namely a privileged helper. + +A separate, bundle executable is installed as a privileged helper using SMJobBless and communicates with the main app (the client) over XPC. This helper performs the post-install and xcode-select tasks that would require sudo from the command line. The helper and main app validate each other's bundle ID, version and code signing certificate chain. Validation of the connection is done using the private audit token API. An alternative is to validate the code signature of the client based on the PID from a first "handshake" message. DTS [seems to say](https://developer.apple.com/forums/thread/72881#420409022) that this would also be safe against an attacker PID-wrapping. Because the SMJobBless + XPC examples I found online all use the audit token instead, I decided to go with that. The tradeoff is that this is private API. + +Uninstallation is not provided yet. I had this partially implemented (one attempt was based on [DoNotDisturb's approach](https://github.com/objective-see/DoNotDisturb/blob/237b19800fa356f830d1c02715a9a75be08b8924/configure/Helper/HelperInterface.m#L123)) but an issue that I kept hitting was that despite the helper not being installed or running I was able to get a remote object proxy over the connection. Adding a timeout to getVersion might be sufficient as a workaround, as it should return the string immediately. + +- [Apple Developer: Creating XPC Services](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingXPCServices.html) +- [Objective Development: The Story Behind CVE-2019-13013](https://blog.obdev.at/what-we-have-learned-from-a-vulnerability/) +- [Apple Developer Forums: How to and When to uninstall a privileged helper](https://developer.apple.com/forums/thread/66821) +- [Apple Developer Forums: XPC restricted to processes with the same code signing?](https://developer.apple.com/forums/thread/72881#419817) +- [Wojciech Reguła: Learn XPC exploitation - Part 1: Broken cryptography](https://wojciechregula.blog/post/learn-xpc-exploitation-part-1-broken-cryptography/) +- [Wojciech Reguła: Learn XPC exploitation - Part 2: Say no to the PID!](https://wojciechregula.blog/post/learn-xpc-exploitation-part-2-say-no-to-the-pid/) +- [Wojciech Reguła: Learn XPC exploitation - Part 3: Code injections](https://wojciechregula.blog/post/learn-xpc-exploitation-part-3-code-injections/) +- [Apple Developer: EvenBetterAuthorizationSample](https://developer.apple.com/library/archive/samplecode/EvenBetterAuthorizationSample/Introduction/Intro.html) +- [erikberglund/SwiftPrivilegedHelper](https://github.com/erikberglund/SwiftPrivilegedHelper) +- [aronskaya/smjobbless](https://github.com/aronskaya/smjobbless) +- [securing/SimpleXPCApp](https://github.com/securing/SimpleXPCApp) diff --git a/HelperXPCShared/HelperXPCShared.swift b/HelperXPCShared/HelperXPCShared.swift new file mode 100644 index 0000000..465fea4 --- /dev/null +++ b/HelperXPCShared/HelperXPCShared.swift @@ -0,0 +1,11 @@ +import Foundation + +let machServiceName = "com.robotsandpencils.XcodesApp.Helper" +let clientBundleID = "com.robotsandpencils.XcodesApp" +let subjectOrganizationalUnit = Bundle.main.infoDictionary!["CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT"] as! String + +@objc(HelperXPCProtocol) +protocol HelperXPCProtocol { + func getVersion(completion: @escaping (String) -> Void) + func xcodeSelect(absolutePath: String, completion: @escaping (Error?) -> Void) +} diff --git a/README.md b/README.md index 13f5d67..81694da 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Xcodes.app is currently only provided as source code that must be built using Xc You'll need macOS 11 Big Sur and Xcode 12 in order to build and run Xcodes.app. +If you aren't a Robots and Pencils employee you'll need to change the CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT build setting to your Apple Developer team ID in order for code signing validation to succeed between the main app and the privileged helper. + Notable design decisions are recorded in [DECISIONS.md](./DECISIONS.md). The Apple authentication flow is described in [Apple.paw](./Apple.paw), which will allow you to play with the API endpoints that are involved using the [Paw](https://paw.cloud) app. [`xcode-install`](https://github.com/xcpretty/xcode-install) and [fastlane/spaceship](https://github.com/fastlane/fastlane/tree/master/spaceship) both deserve credit for figuring out the hard parts of what makes this possible. diff --git a/Scripts/uninstall_privileged_helper.sh b/Scripts/uninstall_privileged_helper.sh new file mode 100755 index 0000000..c3ce84b --- /dev/null +++ b/Scripts/uninstall_privileged_helper.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +PRIVILEGED_HELPER_LABEL=com.robotsandpencils.XcodesApp.Helper + +sudo rm /Library/PrivilegedHelperTools/$PRIVILEGED_HELPER_LABEL +sudo rm /Library/LaunchDaemons/$PRIVILEGED_HELPER_LABEL.plist +sudo launchctl bootout system/$PRIVILEGED_HELPER_LABEL #'Boot-out failed: 36: Operation now in progress' is OK output + +echo "Querying launchd..." +LAUNCHD_OUTPUT=$(sudo launchctl list | grep $PRIVILEGED_HELPER_LABEL) + + +if [ -z "$LAUNCHD_OUTPUT" ] +then + echo "Finished successfully." +else + echo "WARNING: $PRIVILEGED_HELPER_LABEL is not removed" +fi diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 6def8ab..40376b4 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -25,6 +25,15 @@ CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF87A2595293E00E47BAF /* DataSource.swift */; }; CA9FF88125955C7000E47BAF /* AvailableXcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF88025955C7000E47BAF /* AvailableXcode.swift */; }; CA9FF8872595607900E47BAF /* InstalledXcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8862595607900E47BAF /* InstalledXcode.swift */; }; + CA9FF8B12595967A00E47BAF /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8B02595967A00E47BAF /* main.swift */; }; + CA9FF8BC259596CB00E47BAF /* com.robotsandpencils.XcodesApp.Helper in Copy Helper */ = {isa = PBXBuildFile; fileRef = CA9FF8AE2595967A00E47BAF /* com.robotsandpencils.XcodesApp.Helper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + CA9FF8CF25959A9700E47BAF /* HelperXPCShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */; }; + CA9FF8D025959A9700E47BAF /* HelperXPCShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */; }; + CA9FF8DB25959B4000E47BAF /* XPCDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8DA25959B4000E47BAF /* XPCDelegate.swift */; }; + CA9FF8E025959BAA00E47BAF /* ConnectionVerifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8DF25959BAA00E47BAF /* ConnectionVerifier.swift */; }; + CA9FF8E625959BB800E47BAF /* AuditTokenHack.m in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8E525959BB800E47BAF /* AuditTokenHack.m */; }; + CA9FF8F525959CE000E47BAF /* HelperInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8F425959CE000E47BAF /* HelperInstaller.swift */; }; + CA9FF9362595B44700E47BAF /* HelperClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF9352595B44700E47BAF /* HelperClient.swift */; }; CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2C255A5262003FD669 /* AppleAPI */; }; CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; }; CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */; }; @@ -65,6 +74,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + CA9FF8B9259596A000E47BAF /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CAD2E7962449574E00113D76 /* Project object */; + proxyType = 1; + remoteGlobalIDString = CA9FF8AD2595967A00E47BAF; + remoteInfo = com.robotsandpencils.XcodesApp.Helper; + }; CAD2E7B42449575100113D76 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = CAD2E7962449574E00113D76 /* Project object */; @@ -74,6 +90,29 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + CA9FF8AC2595967A00E47BAF /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = /usr/share/man/man1/; + dstSubfolderSpec = 0; + files = ( + ); + runOnlyForDeploymentPostprocessing = 1; + }; + CA9FF8BB259596B500E47BAF /* Copy Helper */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/Library/LaunchServices; + dstSubfolderSpec = 1; + files = ( + CA9FF8BC259596CB00E47BAF /* com.robotsandpencils.XcodesApp.Helper in Copy Helper */, + ); + name = "Copy Helper"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 63EAA4EA259944450046AB8F /* ProgressButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressButton.swift; sourceTree = ""; }; CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = ""; }; @@ -97,6 +136,20 @@ CA9FF87A2595293E00E47BAF /* DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSource.swift; sourceTree = ""; }; CA9FF88025955C7000E47BAF /* AvailableXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailableXcode.swift; sourceTree = ""; }; CA9FF8862595607900E47BAF /* InstalledXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledXcode.swift; sourceTree = ""; }; + CA9FF8AE2595967A00E47BAF /* com.robotsandpencils.XcodesApp.Helper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = com.robotsandpencils.XcodesApp.Helper; sourceTree = BUILT_PRODUCTS_DIR; }; + CA9FF8B02595967A00E47BAF /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + CA9FF8C22595988B00E47BAF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + CA9FF8C32595989800E47BAF /* launchd.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = launchd.plist; sourceTree = ""; }; + CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperXPCShared.swift; sourceTree = ""; }; + CA9FF8DA25959B4000E47BAF /* XPCDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XPCDelegate.swift; sourceTree = ""; }; + CA9FF8DF25959BAA00E47BAF /* ConnectionVerifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionVerifier.swift; sourceTree = ""; }; + CA9FF8E425959BB800E47BAF /* AuditTokenHack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AuditTokenHack.h; sourceTree = ""; }; + CA9FF8E525959BB800E47BAF /* AuditTokenHack.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AuditTokenHack.m; sourceTree = ""; }; + CA9FF8EA25959BDD00E47BAF /* com.robotsandpencils.XcodesApp.Helper-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "com.robotsandpencils.XcodesApp.Helper-Bridging-Header.h"; sourceTree = ""; }; + CA9FF8F425959CE000E47BAF /* HelperInstaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HelperInstaller.swift; sourceTree = ""; }; + CA9FF9052595A28400E47BAF /* com.robotsandpencils.XcodesApp.HelperTest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = com.robotsandpencils.XcodesApp.HelperTest.entitlements; sourceTree = ""; }; + CA9FF9252595A7EB00E47BAF /* Scripts */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Scripts; sourceTree = ""; }; + CA9FF9352595B44700E47BAF /* HelperClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperClient.swift; sourceTree = ""; }; CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsView.swift; sourceTree = ""; }; CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignIn2FAView.swift; sourceTree = ""; }; CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSMSView.swift; sourceTree = ""; }; @@ -138,6 +191,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + CA9FF8AB2595967A00E47BAF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAD2E79B2449574E00113D76 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -189,6 +249,30 @@ path = About; sourceTree = ""; }; + CA9FF8AF2595967A00E47BAF /* com.robotsandpencils.XcodesApp.Helper */ = { + isa = PBXGroup; + children = ( + CA9FF8E425959BB800E47BAF /* AuditTokenHack.h */, + CA9FF8E525959BB800E47BAF /* AuditTokenHack.m */, + CA9FF8EA25959BDD00E47BAF /* com.robotsandpencils.XcodesApp.Helper-Bridging-Header.h */, + CA9FF8DF25959BAA00E47BAF /* ConnectionVerifier.swift */, + CA9FF8B02595967A00E47BAF /* main.swift */, + CA9FF8DA25959B4000E47BAF /* XPCDelegate.swift */, + CA9FF8C22595988B00E47BAF /* Info.plist */, + CA9FF8C32595989800E47BAF /* launchd.plist */, + CA9FF9052595A28400E47BAF /* com.robotsandpencils.XcodesApp.HelperTest.entitlements */, + ); + path = com.robotsandpencils.XcodesApp.Helper; + sourceTree = ""; + }; + CA9FF8CD25959A7600E47BAF /* HelperXPCShared */ = { + isa = PBXGroup; + children = ( + CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */, + ); + path = HelperXPCShared; + sourceTree = ""; + }; CAA1CB50255A5D16003FD669 /* SignIn */ = { isa = PBXGroup; children = ( @@ -229,6 +313,8 @@ CABFA9B82592EEEA00380FEE /* FileManager+.swift */, CAFBDB942598FE96003DCC5A /* FocusedValues.swift */, CABFA9AC2592EEE900380FEE /* Foundation.swift */, + CA9FF8F425959CE000E47BAF /* HelperInstaller.swift */, + CA9FF9352595B44700E47BAF /* HelperClient.swift */, CA9FF8862595607900E47BAF /* InstalledXcode.swift */, CABFA9AE2592EEE900380FEE /* Path+.swift */, CABFA9B42592EEEA00380FEE /* Process.swift */, @@ -279,10 +365,13 @@ CABFA9A32592ED5700380FEE /* Apple.paw */, CABFA9A12592EAFB00380FEE /* LICENSE */, CA8FB61C256E115700469DA5 /* .github */, + CA9FF9252595A7EB00E47BAF /* Scripts */, CA9FF8242594F10700E47BAF /* AcknowledgementsGenerator */, CA538A0C255A4F1A00E64DD7 /* AppleAPI */, CAD2E7A02449574E00113D76 /* Xcodes */, CAD2E7B62449575100113D76 /* XcodesTests */, + CA9FF8AF2595967A00E47BAF /* com.robotsandpencils.XcodesApp.Helper */, + CA9FF8CD25959A7600E47BAF /* HelperXPCShared */, CAD2E79F2449574E00113D76 /* Products */, CA538A12255A4F7C00E64DD7 /* Frameworks */, ); @@ -293,6 +382,7 @@ children = ( CAD2E79E2449574E00113D76 /* Xcodes.app */, CAD2E7B32449575100113D76 /* XcodesTests.xctest */, + CA9FF8AE2595967A00E47BAF /* com.robotsandpencils.XcodesApp.Helper */, ); name = Products; sourceTree = ""; @@ -329,6 +419,23 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + CA9FF8AD2595967A00E47BAF /* com.robotsandpencils.XcodesApp.Helper */ = { + isa = PBXNativeTarget; + buildConfigurationList = CA9FF8B52595967A00E47BAF /* Build configuration list for PBXNativeTarget "com.robotsandpencils.XcodesApp.Helper" */; + buildPhases = ( + CA9FF8AA2595967A00E47BAF /* Sources */, + CA9FF8AB2595967A00E47BAF /* Frameworks */, + CA9FF8AC2595967A00E47BAF /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = com.robotsandpencils.XcodesApp.Helper; + productName = com.robotsandpencils.XcodesApp.Helper; + productReference = CA9FF8AE2595967A00E47BAF /* com.robotsandpencils.XcodesApp.Helper */; + productType = "com.apple.product-type.tool"; + }; CAD2E79D2449574E00113D76 /* Xcodes */ = { isa = PBXNativeTarget; buildConfigurationList = CAD2E7BC2449575100113D76 /* Build configuration list for PBXNativeTarget "Xcodes" */; @@ -337,10 +444,12 @@ CAD2E79B2449574E00113D76 /* Frameworks */, CA9FF8292594F33200E47BAF /* Generate Acknowledgements */, CAD2E79C2449574E00113D76 /* Resources */, + CA9FF8BB259596B500E47BAF /* Copy Helper */, ); buildRules = ( ); dependencies = ( + CA9FF8BA259596A000E47BAF /* PBXTargetDependency */, ); name = Xcodes; packageProductDependencies = ( @@ -382,10 +491,13 @@ CAD2E7962449574E00113D76 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1140; + LastSwiftUpdateCheck = 1220; LastUpgradeCheck = 1140; ORGANIZATIONNAME = "Robots and Pencils"; TargetAttributes = { + CA9FF8AD2595967A00E47BAF = { + CreatedOnToolsVersion = 12.2; + }; CAD2E79D2449574E00113D76 = { CreatedOnToolsVersion = 11.4; }; @@ -420,6 +532,7 @@ targets = ( CAD2E79D2449574E00113D76 /* Xcodes */, CAD2E7B22449575100113D76 /* XcodesTests */, + CA9FF8AD2595967A00E47BAF /* com.robotsandpencils.XcodesApp.Helper */, ); }; /* End PBXProject section */ @@ -466,14 +579,28 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + CA9FF8AA2595967A00E47BAF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CA9FF8D025959A9700E47BAF /* HelperXPCShared.swift in Sources */, + CA9FF8DB25959B4000E47BAF /* XPCDelegate.swift in Sources */, + CA9FF8E625959BB800E47BAF /* AuditTokenHack.m in Sources */, + CA9FF8B12595967A00E47BAF /* main.swift in Sources */, + CA9FF8E025959BAA00E47BAF /* ConnectionVerifier.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAD2E79A2449574E00113D76 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CA9FF8CF25959A9700E47BAF /* HelperXPCShared.swift in Sources */, CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */, CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */, CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */, CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */, + CA9FF9362595B44700E47BAF /* HelperClient.swift in Sources */, CABFA9CA2592EEEA00380FEE /* AppState+Update.swift in Sources */, CA44901F2463AD34003D8213 /* Tag.swift in Sources */, CABFA9BF2592EEEA00380FEE /* URLSession+Promise.swift in Sources */, @@ -496,6 +623,7 @@ CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */, CA9FF877259528CC00E47BAF /* Version+XcodeReleases.swift in Sources */, CABFAA2D2592FBFC00380FEE /* Configure.swift in Sources */, + CA9FF8F525959CE000E47BAF /* HelperInstaller.swift in Sources */, CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */, CABFA9C22592EEEA00380FEE /* Promise+.swift in Sources */, @@ -526,6 +654,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + CA9FF8BA259596A000E47BAF /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CA9FF8AD2595967A00E47BAF /* com.robotsandpencils.XcodesApp.Helper */; + targetProxy = CA9FF8B9259596A000E47BAF /* PBXContainerItemProxy */; + }; CAD2E7B52449575100113D76 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = CAD2E79D2449574E00113D76 /* Xcodes */; @@ -566,6 +699,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT = PBH8V487HB; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -641,6 +775,74 @@ }; name = Test; }; + CA9FF8B22595967A00E47BAF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_STYLE = Automatic; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + DEVELOPMENT_TEAM = PBH8V487HB; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = "$(SRCROOT)/$(TARGET_NAME)/Info.plist"; + OTHER_LDFLAGS = ( + "-sectcreate", + __TEXT, + __launchd_plist, + "$(SRCROOT)/${TARGET_NAME}/launchd.plist", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.robotsandpencils.XcodesApp.Helper; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "com.robotsandpencils.XcodesApp.Helper/com.robotsandpencils.XcodesApp.Helper-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + CA9FF8B32595967A00E47BAF /* Test */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = com.robotsandpencils.XcodesApp.Helper/com.robotsandpencils.XcodesApp.HelperTest.entitlements; + CODE_SIGN_STYLE = Manual; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = NO; + INFOPLIST_FILE = "$(SRCROOT)/$(TARGET_NAME)/Info.plist"; + OTHER_LDFLAGS = ( + "-sectcreate", + __TEXT, + __launchd_plist, + "$(SRCROOT)/${TARGET_NAME}/launchd.plist", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.robotsandpencils.XcodesApp.Helper; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "com.robotsandpencils.XcodesApp.Helper/com.robotsandpencils.XcodesApp.Helper-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + }; + name = Test; + }; + CA9FF8B42595967A00E47BAF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_STYLE = Automatic; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + DEVELOPMENT_TEAM = PBH8V487HB; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = "$(SRCROOT)/$(TARGET_NAME)/Info.plist"; + OTHER_LDFLAGS = ( + "-sectcreate", + __TEXT, + __launchd_plist, + "$(SRCROOT)/${TARGET_NAME}/launchd.plist", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.robotsandpencils.XcodesApp.Helper; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "com.robotsandpencils.XcodesApp.Helper/com.robotsandpencils.XcodesApp.Helper-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; CAD2E7BA2449575100113D76 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -673,6 +875,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT = PBH8V487HB; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -733,6 +936,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT = PBH8V487HB; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; @@ -843,6 +1047,16 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + CA9FF8B52595967A00E47BAF /* Build configuration list for PBXNativeTarget "com.robotsandpencils.XcodesApp.Helper" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CA9FF8B22595967A00E47BAF /* Debug */, + CA9FF8B32595967A00E47BAF /* Test */, + CA9FF8B42595967A00E47BAF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; CAD2E7992449574E00113D76 /* Build configuration list for PBXProject "Xcodes" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index ffa4516..1fd3c5c 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -9,7 +9,9 @@ import Version class AppState: ObservableObject { private let client = AppleAPI.Client() + private let helperClient = HelperClient() private var cancellables = Set() + private var selectPublisher: AnyCancellable? @Published var authenticationState: AuthenticationState = .unauthenticated @Published var availableXcodes: [AvailableXcode] = [] { @@ -30,9 +32,11 @@ class AppState: ObservableObject { // but we need it here instead so that it can be a focusedValue at the top level in XcodesApp instead of in a list row. The latter seems more like how the focusedValue API is supposed to work, but currently doesn't. @Published var selectedXcodeID: Xcode.ID? @Published var xcodeBeingConfirmedForUninstallation: Xcode? + @Published var helperInstallState: HelperInstallState = .notInstalled init() { try? loadCachedAvailableXcodes() + checkIfHelperIsInstalled() } // MARK: - Authentication @@ -175,6 +179,26 @@ class AppState: ObservableObject { authenticationState = .unauthenticated } + // MARK: - Helper + + func installHelper() { + HelperInstaller.install() + checkIfHelperIsInstalled() + } + + private func checkIfHelperIsInstalled() { + helperInstallState = .unknown + + helperClient.checkIfLatestHelperIsInstalled() + .receive(on: DispatchQueue.main) + .sink( + receiveValue: { installed in + self.helperInstallState = installed ? .installed : .notInstalled + } + ) + .store(in: &cancellables) + } + // MARK: - func install(id: Xcode.ID) { @@ -192,7 +216,25 @@ class AppState: ObservableObject { } func select(id: Xcode.ID) { - // TODO: + if helperInstallState == .notInstalled { + installHelper() + } + + guard + let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version == id }), + selectPublisher == nil + else { return } + + selectPublisher = HelperClient().switchXcodePath(installedXcode.path.string) + .sink( + receiveCompletion: { [unowned self] completion in + if case let .failure(error) = completion { + self.error = AlertContent(title: "Error selecting Xcode", message: error.legibleLocalizedDescription) + } + self.selectPublisher = nil + }, + receiveValue: { _ in } + ) } func launch(id: Xcode.ID) { @@ -245,6 +287,12 @@ class AppState: ObservableObject { // MARK: - Nested Types + enum HelperInstallState: Equatable { + case unknown + case notInstalled + case installed + } + struct AlertContent: Identifiable { var title: String var message: String diff --git a/Xcodes/Backend/HelperClient.swift b/Xcodes/Backend/HelperClient.swift new file mode 100644 index 0000000..5ab5c7b --- /dev/null +++ b/Xcodes/Backend/HelperClient.swift @@ -0,0 +1,106 @@ +import Combine +import Foundation + +final class HelperClient { + private var connection: NSXPCConnection? + + func currentConnection() -> NSXPCConnection? { + guard self.connection == nil else { + return self.connection + } + + let connection = NSXPCConnection(machServiceName: machServiceName, options: .privileged) + connection.remoteObjectInterface = NSXPCInterface(with: HelperXPCProtocol.self) + connection.invalidationHandler = { + self.connection?.invalidationHandler = nil + DispatchQueue.main.async { + self.connection = nil + } + } + + self.connection = connection + connection.resume() + + return self.connection + } + + func helper(errorSubject: PassthroughSubject) -> HelperXPCProtocol? { + guard + let helper = self.currentConnection()?.remoteObjectProxyWithErrorHandler({ error in + errorSubject.send(completion: .failure(error)) + }) as? HelperXPCProtocol + else { return nil } + return helper + } + + func checkIfLatestHelperIsInstalled() -> AnyPublisher { + let helperURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LaunchServices/" + machServiceName) + guard + let helperBundleInfo = CFBundleCopyInfoDictionaryForURL(helperURL as CFURL) as? [String: Any], + let bundledHelperVersion = helperBundleInfo["CFBundleShortVersionString"] as? String + else { + return Just(false).eraseToAnyPublisher() + } + + return getVersion() + .map { installedHelperVersion in installedHelperVersion == bundledHelperVersion } + .catch { _ in Just(false) } + .eraseToAnyPublisher() + } + + func getVersion() -> AnyPublisher { + let connectionErrorSubject = PassthroughSubject() + guard + let helper = self.helper(errorSubject: connectionErrorSubject) + else { + return Fail(error: NSError()) + .eraseToAnyPublisher() + } + + return Deferred { + Future { promise in + helper.getVersion { version in + promise(.success(version)) + } + } + } + // Take values, but fail when connectionErrorSubject fails + .zip( + connectionErrorSubject + .prepend("") + .map { _ in Void() } + ) + .map { $0.0 } + .eraseToAnyPublisher() + } + + func switchXcodePath(_ absolutePath: String) -> AnyPublisher { + let connectionErrorSubject = PassthroughSubject() + guard + let helper = self.helper(errorSubject: connectionErrorSubject) + else { + return Fail(error: NSError()) + .eraseToAnyPublisher() + } + + return Deferred { + Future { promise in + helper.xcodeSelect(absolutePath: absolutePath, completion: { (possibleError) in + if let error = possibleError { + promise(.failure(error)) + } else { + promise(.success(())) + } + }) + } + } + // Take values, but fail when connectionErrorSubject fails + .zip( + connectionErrorSubject + .prepend("") + .map { _ in Void() } + ) + .map { $0.0 } + .eraseToAnyPublisher() + } +} diff --git a/Xcodes/Backend/HelperInstaller.swift b/Xcodes/Backend/HelperInstaller.swift new file mode 100644 index 0000000..e6d6bbb --- /dev/null +++ b/Xcodes/Backend/HelperInstaller.swift @@ -0,0 +1,64 @@ +// From https://github.com/securing/SimpleXPCApp/ +// MIT License +// +// Copyright (c) 2020 securing +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// Installer implemented basing on https://github.com/erikberglund/SwiftPrivilegedHelper + +import Foundation +import ServiceManagement + +enum HelperAuthorizationError: Error { + case message(String) +} + +class HelperInstaller { + private static func executeAuthorizationFunction(_ authorizationFunction: () -> (OSStatus) ) throws { + let osStatus = authorizationFunction() + guard osStatus == errAuthorizationSuccess else { + throw HelperAuthorizationError.message(String(describing: SecCopyErrorMessageString(osStatus, nil))) + } + } + + static func authorizationRef(_ rights: UnsafePointer?, + _ environment: UnsafePointer?, + _ flags: AuthorizationFlags) throws -> AuthorizationRef? { + var authRef: AuthorizationRef? + try executeAuthorizationFunction { AuthorizationCreate(rights, environment, flags, &authRef) } + return authRef + } + + static func install() { + var authItem = kSMRightBlessPrivilegedHelper.withCString { name in + AuthorizationItem(name: name, valueLength: 0, value:UnsafeMutableRawPointer(bitPattern: 0), flags: 0) + } + var authRights = withUnsafeMutablePointer(to: &authItem) { authItem in + AuthorizationRights(count: 1, items: authItem) + } + + do { + let authRef = try authorizationRef(&authRights, nil, [.interactionAllowed, .extendRights, .preAuthorize]) + var cfError: Unmanaged? + SMJobBless(kSMDomainSystemLaunchd, machServiceName as CFString, authRef, &cfError) + } catch let err { + print("Error in installing the helper -> \(err.localizedDescription)") + } + } +} diff --git a/Xcodes/Resources/Info.plist b/Xcodes/Resources/Info.plist index a09a55c..661015d 100644 --- a/Xcodes/Resources/Info.plist +++ b/Xcodes/Resources/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0 + 1.0.0 CFBundleVersion 1 LSMinimumSystemVersion @@ -30,5 +30,12 @@ NSSupportsSuddenTermination + SMPrivilegedExecutables + + com.robotsandpencils.XcodesApp.Helper + identifier "com.robotsandpencils.XcodesApp.Helper" and info [CFBundleShortVersionString] >= "1.0.0" and anchor apple generic and certificate leaf[subject.OU] = "$(CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT)" + + CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT + $(CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT) diff --git a/Xcodes/SettingsView.swift b/Xcodes/SettingsView.swift index 5ff2646..7b4d9b4 100644 --- a/Xcodes/SettingsView.swift +++ b/Xcodes/SettingsView.swift @@ -38,6 +38,28 @@ struct SettingsView: View { } .frame(maxWidth: .infinity, alignment: .leading) } + + GroupBox(label: Text("Privileged Helper")) { + VStack(alignment: .leading, spacing: 8) { + switch appState.helperInstallState { + case .unknown: + ProgressView() + .scaleEffect(0.5, anchor: .center) + case .installed: + Text("Helper is installed") + case .notInstalled: + HStack { + Text("Helper is not installed") + Button("Install helper") { + appState.installHelper() + } + } + } + + Text("Xcodes uses a separate privileged helper to perform tasks as root. These are things that would require sudo on the command line, including post-install steps and switching Xcode versions with xcode-select.") + .font(.footnote) + } + } Spacer() } .padding() diff --git a/com.robotsandpencils.XcodesApp.Helper/AuditTokenHack.h b/com.robotsandpencils.XcodesApp.Helper/AuditTokenHack.h new file mode 100644 index 0000000..ae723f4 --- /dev/null +++ b/com.robotsandpencils.XcodesApp.Helper/AuditTokenHack.h @@ -0,0 +1,37 @@ +// From https://github.com/securing/SimpleXPCApp/ +// MIT License +// +// Copyright (c) 2020 securing +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import + +@interface NSXPCConnection(PrivateAuditToken) + +@property (nonatomic, readonly) audit_token_t auditToken; + +@end + + +@interface AuditTokenHack : NSObject + ++(NSData *)getAuditTokenDataFromNSXPCConnection:(NSXPCConnection *)connection; + +@end diff --git a/com.robotsandpencils.XcodesApp.Helper/AuditTokenHack.m b/com.robotsandpencils.XcodesApp.Helper/AuditTokenHack.m new file mode 100644 index 0000000..669677a --- /dev/null +++ b/com.robotsandpencils.XcodesApp.Helper/AuditTokenHack.m @@ -0,0 +1,33 @@ +// From https://github.com/securing/SimpleXPCApp/ +// MIT License +// +// Copyright (c) 2020 securing +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import "AuditTokenHack.h" + +@implementation AuditTokenHack + ++ (NSData *)getAuditTokenDataFromNSXPCConnection:(NSXPCConnection *)connection { + audit_token_t auditToken = connection.auditToken; + return [NSData dataWithBytes:&auditToken length:sizeof(audit_token_t)]; +} + +@end diff --git a/com.robotsandpencils.XcodesApp.Helper/ConnectionVerifier.swift b/com.robotsandpencils.XcodesApp.Helper/ConnectionVerifier.swift new file mode 100644 index 0000000..02ba430 --- /dev/null +++ b/com.robotsandpencils.XcodesApp.Helper/ConnectionVerifier.swift @@ -0,0 +1,144 @@ +// From https://github.com/securing/SimpleXPCApp/ +// MIT License +// +// Copyright (c) 2020 securing +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +class ConnectionVerifier { + + private static func prepareCodeReferencesFromAuditToken(connection: NSXPCConnection, secCodeOptional: inout SecCode?, secStaticCodeOptional: inout SecStaticCode?) -> Bool { + let auditTokenData = AuditTokenHack.getAuditTokenData(from: connection) + + let attributesDictrionary = [ + kSecGuestAttributeAudit : auditTokenData + ] + + if SecCodeCopyGuestWithAttributes(nil, attributesDictrionary as CFDictionary, SecCSFlags(rawValue: 0), &secCodeOptional) != errSecSuccess { + NSLog("Couldn't get SecCode with the audit token") + return false + } + + guard let secCode = secCodeOptional else { + NSLog("Couldn't unwrap the secCode") + return false + } + + SecCodeCopyStaticCode(secCode, SecCSFlags(rawValue: 0), &secStaticCodeOptional) + + guard let _ = secStaticCodeOptional else { + NSLog("Couldn't unwrap the secStaticCode") + return false + } + + return true + } + + private static func verifyHardenedRuntimeAndProblematicEntitlements(secStaticCode: SecStaticCode) -> Bool { + var signingInformationOptional: CFDictionary? = nil + if SecCodeCopySigningInformation(secStaticCode, SecCSFlags(rawValue: kSecCSDynamicInformation), &signingInformationOptional) != errSecSuccess { + NSLog("Couldn't obtain signing information") + return false + } + + guard let signingInformation = signingInformationOptional else { + return false + } + + let signingInformationDict = signingInformation as NSDictionary + + let signingFlagsOptional = signingInformationDict.object(forKey: "flags") as? UInt32 + + if let signingFlags = signingFlagsOptional { + let hardenedRuntimeFlag: UInt32 = 0x10000 + if (signingFlags & hardenedRuntimeFlag) != hardenedRuntimeFlag { + NSLog("Hardened runtime is not set for the sender") + return false + } + } else { + return false + } + + let entitlementsOptional = signingInformationDict.object(forKey: "entitlements-dict") as? NSDictionary + guard let entitlements = entitlementsOptional else { + return false + } + NSLog("Entitlements are \(entitlements)") + let problematicEntitlements = [ + "com.apple.security.get-task-allow", + "com.apple.security.cs.disable-library-validation", + "com.apple.security.cs.allow-dyld-environment-variables" + ] + + // Skip this check for debug builds because they'll have the get-task-allow entitlement + #if !DEBUG + for problematicEntitlement in problematicEntitlements { + if let presentEntitlement = entitlements.object(forKey: problematicEntitlement) { + if presentEntitlement as! Int == 1 { + NSLog("The sender has \(problematicEntitlement) entitlement set to true") + return false + } + } + } + #endif + + return true + } + + private static func verifyWithRequirementString(secCode: SecCode) -> Bool { + // Code Signing Requirement Language + // https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/RequirementLang/RequirementLang.html#//apple_ref/doc/uid/TP40005929-CH5-SW1 + let requirementString = "identifier \"\(clientBundleID)\" and info [CFBundleShortVersionString] >= \"1.0.0\" and anchor apple generic and certificate leaf[subject.OU] = \"\(subjectOrganizationalUnit)\"" as NSString + + var secRequirement: SecRequirement? = nil + if SecRequirementCreateWithString(requirementString as CFString, SecCSFlags(rawValue: 0), &secRequirement) != errSecSuccess { + NSLog("Couldn't create the requirement string") + return false + } + + if SecCodeCheckValidity(secCode, SecCSFlags(rawValue: 0), secRequirement) != errSecSuccess { + NSLog("NSXPC client does not meet the requirements") + return false + } + + return true + } + + public static func isValid(connection: NSXPCConnection) -> Bool { + var secCodeOptional: SecCode? = nil + var secStaticCodeOptional: SecStaticCode? = nil + + if !prepareCodeReferencesFromAuditToken(connection: connection, secCodeOptional: &secCodeOptional, secStaticCodeOptional: &secStaticCodeOptional) { + return false + } + + if !verifyHardenedRuntimeAndProblematicEntitlements(secStaticCode: secStaticCodeOptional!) { + return false + } + + if !verifyWithRequirementString(secCode: secCodeOptional!) { + return false + } + + return true + } + +} diff --git a/com.robotsandpencils.XcodesApp.Helper/Info.plist b/com.robotsandpencils.XcodesApp.Helper/Info.plist new file mode 100644 index 0000000..58c895c --- /dev/null +++ b/com.robotsandpencils.XcodesApp.Helper/Info.plist @@ -0,0 +1,18 @@ + + + + + CFBundleIdentifier + ${PRODUCT_BUNDLE_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleShortVersionString + 1.0.0 + SMAuthorizedClients + + identifier "com.robotsandpencils.XcodesApp" and info [CFBundleShortVersionString] >= "1.0.0" and anchor apple generic and certificate leaf[subject.OU] = "$(CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT)" + + CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT + $(CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT) + + diff --git a/com.robotsandpencils.XcodesApp.Helper/XPCDelegate.swift b/com.robotsandpencils.XcodesApp.Helper/XPCDelegate.swift new file mode 100644 index 0000000..6ceab7c --- /dev/null +++ b/com.robotsandpencils.XcodesApp.Helper/XPCDelegate.swift @@ -0,0 +1,85 @@ +import Foundation + +class XPCDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol { + + // MARK: - NSXPCListenerDelegate + + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + guard ConnectionVerifier.isValid(connection: newConnection) else { return false } + + newConnection.exportedInterface = NSXPCInterface(with: HelperXPCProtocol.self) + newConnection.exportedObject = self + newConnection.resume() + return true + } + + // MARK: - HelperXPCProtocol + + func getVersion(completion: @escaping (String) -> Void) { + NSLog("XPCDelegate: \(#function)") + completion(Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String) + } + + func xcodeSelect(absolutePath: String, completion: @escaping (Error?) -> Void) { + NSLog("XPCDelegate: \(#function)") + + guard URL(fileURLWithPath: absolutePath).hasDirectoryPath else { + completion(XPCDelegateError(.invalidXcodePath)) + return + } + + run( + url: URL(fileURLWithPath: "/usr/bin/xcode-select"), + arguments: ["-s", absolutePath], + completion: completion + ) + } +} + +// MARK: - Run + +private func run(url: URL, arguments: [String], completion: @escaping (Error?) -> Void) { + NSLog("XPCDelegate: run \(url) \(arguments)") + + let process = Process() + process.executableURL = url + process.arguments = arguments + do { + try process.run() + process.waitUntilExit() + completion(nil) + } catch { + completion(error) + } +} + + +// MARK: - Errors + +struct XPCDelegateError: CustomNSError { + enum Code: Int { + case invalidXcodePath + } + + let code: Code + + init(_ code: Code) { + self.code = code + } + + // MARK: - CustomNSError + + static var errorDomain: String { "XPCDelegateError" } + + var errorCode: Int { code.rawValue } + + var errorUserInfo: [String : Any] { + switch code { + case .invalidXcodePath: + return [ + NSLocalizedDescriptionKey: "Invalid Xcode path.", + NSLocalizedFailureReasonErrorKey: "Xcode path must be absolute." + ] + } + } +} diff --git a/com.robotsandpencils.XcodesApp.Helper/com.robotsandpencils.XcodesApp.Helper-Bridging-Header.h b/com.robotsandpencils.XcodesApp.Helper/com.robotsandpencils.XcodesApp.Helper-Bridging-Header.h new file mode 100644 index 0000000..d268bdf --- /dev/null +++ b/com.robotsandpencils.XcodesApp.Helper/com.robotsandpencils.XcodesApp.Helper-Bridging-Header.h @@ -0,0 +1 @@ +#import "AuditTokenHack.h" diff --git a/com.robotsandpencils.XcodesApp.Helper/com.robotsandpencils.XcodesApp.HelperTest.entitlements b/com.robotsandpencils.XcodesApp.Helper/com.robotsandpencils.XcodesApp.HelperTest.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/com.robotsandpencils.XcodesApp.Helper/com.robotsandpencils.XcodesApp.HelperTest.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/com.robotsandpencils.XcodesApp.Helper/launchd.plist b/com.robotsandpencils.XcodesApp.Helper/launchd.plist new file mode 100644 index 0000000..29f2559 --- /dev/null +++ b/com.robotsandpencils.XcodesApp.Helper/launchd.plist @@ -0,0 +1,13 @@ + + + + + Label + com.robotsandpencils.XcodesApp.Helper + MachServices + + com.robotsandpencils.XcodesApp.Helper + + + + diff --git a/com.robotsandpencils.XcodesApp.Helper/main.swift b/com.robotsandpencils.XcodesApp.Helper/main.swift new file mode 100644 index 0000000..14d67e1 --- /dev/null +++ b/com.robotsandpencils.XcodesApp.Helper/main.swift @@ -0,0 +1,9 @@ +import Foundation + +let listener = NSXPCListener.init(machServiceName: machServiceName) +let delegate = XPCDelegate() + +listener.delegate = delegate +listener.resume() + +RunLoop.main.run()