SwiftAuthorizationSample demonstrates how to run privileged operations on macOS using a helper tool managed by launchd.
This sample was created with the expectation that you already have an app and are looking to add a privileged helper tool in order to perform one or more operations as root. As such this sample is not a template and is instead written in a modular way which should make it easy for you to add portions of this code to your project as desired.
To try out this sample, configure your Development signing certificate for both the app and helper tool, then you should be good to go. Sign to Run Locally is not supported as this would result in code signing requirements which cannot securely identify the app and helper tool.
Read the following sections to learn how you can incorporate portions of this sample into your own project. The source code of the sample also contains comments throughout.
If you run into issues with this sample it may be because of Xcode compatibility issues; this sample was created with Xcode 13.
When you run the sample you should see an app which looks like this (shown in dark mode):
Initially the helper tool won't be installed, so start by trying out that functionality.
Once the helper tool is installed you can run any of the privileged commands. These are a pretty arbitrary set of macOS Command Line Tools and some of the arguments you can pass to them. The only commonality between them is they require root access to run (and are non-destructive, since it is a sample after all).
Two of the commands say "[Auth Required]" and when run will require you to provide an admin password. This is done to demonstrate how, if you so desire, you can self-restrict access to portions of your helper tool.
This sample targets macOS 10.14.4 and above. If you would like to support pre-10.14.4 versions of macOS, the helper tool cannot be written in Swift or Swift 5 Runtime Support for Command Line Tools must be installed. This is because the helper tool must be a Command Line Tool (not an app bundle) and starting with Swift 5 and Xcode 10.2, Apple made the decision to end support for embedding the Swift runtime into Command Line Tools.
Note: The helper tool, once installed, will not be run from inside of your app bundle and so it cannot target any Swift runtime bundled with your app. (This is unlike XPC Services which may do this.)
All three Swift frameworks used by this sample target macOS 10.10 and later.
Three Swift frameworks were created specifically for this helper tool use case:
- Blessed: Helper tool installation
- Makes SMJobBless functionality a single function call; no need to directly use Authorization Services
- Enables advanced use cases with a full implementation of Authorization Services and Service Management
- SecureXPC: Communication between your app and helper tool
- Easily send and receive Codable instances
- Designed specifically for secure XPC Mach Services connections, which by default allow any process to communicate
- EmbeddedPropertyList: Embedded property list reader
- Directly read the info and launchd property lists embedded in the helper tool
These are collectively available as a package collection which you can add to Xcode 13 or later.
Each of these frameworks have their own READMEs as well as full DocC documentation.
macOS allows non-sandboxed apps to indirectly run code as root by installing a privileged helper tool. If you were to
directly use Apple's APIs you'd use the
Authorization Services
framework to have the user authenticate as an admin and then call
SMJobBless
to perform
the installation. The Blessed framework used by this sample simplifies this
to just one function call.
If this operation succeeds the helper tool will be copied from the Contents/Library/LaunchServices
directory inside
your app bundle to /Library/PrivilegedHelperTools/
. Once installed, it is managed by
launchd.
For this operation to succeed, Apple imposes numerous requirements:
- Your app must be signed.
- The helper tool must be signed.
- The helper tool must be located in the
Contents/Library/LaunchServices
directory inside your app's bundle. - The filename of the helper tool should be reverse-DNS format.
- If your app has the bundle identifier "com.example.SwiftAuthorizationApp" then your helper tool may have a filename of "com.example.SwiftAuthorizationApp.helper".
- The helper tool must have an embedded launchd property list.
- The helper tool's embedded launchd property list must have an entry with
Label
as the key and the value must be the filename of the helper tool. - The helper tool must have an embedded info property list.
- The helper tool's embedded info property list must have an entry with
SMAuthorizedClients
as its key and its value must be an array of strings. Each string must be a code signing requirement. Your app must satisify at least one of these requirements.- Only processes which meet one or more of these requirements may install or update the helper tool.
- These requirements are only about which processes may install or update the helper tool. They impose no restrictions on which processes can communicate with the helper tool.
- The helper tool's embedded info property list must have an entry with
CFBundleVersion
as its key and its value must be a string matching the format described inCFBundleVersion
's documentation.- This requirement is not documented by Apple, but is enforced.
- While not documented by Apple,
SMJobBless
will not overwrite an existing installation of a helper tool with one that has an equal or lower value for itsCFBundleVersion
entry. - Despite Apple requiring the info property list contain a key named
CFBundleVersion
, your helper tool must be a Command Line Tool and must not be a bundle.
- Your app's Info.plist must have an entry with
SMPrivilegedExecutables
as its key and its value must be a dictionary. Each dictionary key must be a helper tool's filename; for example "com.example.SwiftAuthorizationApp.helper". Each dictionary value must be a string representation of a code signing requirement that the helper tool satisfies.
While Apple imposes numerous requirements, many of them only need to be configured once. For the remainder, this sample uses build variables and a custom build script to automate the process. In particular the build script handles:
- Generating the
SMPrivilegedExecutables
entry - Generating the
SMAuthorizedClients
entry, with a code signing requirement that mitigates downgrade attacks - Incrementing the helper tool's
CFBundleVersion
so that it can be updated - Ensuring the helper tool's launchd
Label
value matches the helper tool's filename
Additionally the build variables, build scripts, and sample code are designed to avoid any duplicative hard coding of values. If you follow the pattern used in this sample and ever wanted to change these values, you'd only need to update them in one place each.
This section walks your through satisfying all of Apple's requirements.
Xcode can automatically code sign for you. If you don't already have an Apple Developer ID you'll need to provision one. If your build process does not use Xcode for signing as part of the build process you'll likely need to modify the PropertyListModifier.swift build script.
The exact steps for this may differ in future versions of Xcode. As of Xcode 13:
- Open your project's
xcodeproj
file - Select your app's target
- Switch to the Build Phases tab
- Create a Copy Files Phase
- Set the Destination as "Wrapper"
- Set the Subpath to "Contents/Library/LaunchServices"
- Add the helper tool product, for example "com.example.SwiftAuthorizationApp.helper"
- Make sure "Code Sign on Copy" is checked
This sample relies on build variables to satisfy several of these requirements. You will need to set these build variables for three different places:
- the project
- the app target
- the helper tool target
The sample uses xcconfig
files; however, you may do this using your xcodeproj
file's Build Settings sections if you
prefer. If you would like to use xcconfig
files, but are unfamiliar with them then read through this
excellent article by NSHipster.
For all of the entries needed, see the following xcconfig
files:
- Config.xcconfig
- SwiftAuthorizationApp/AppConfig.xcconfig
- SwiftAuthorizationHelperTool/HelperToolConfig.xcconfig
Note that the setting of some target specific values in the project level Config.xcconfig is essential as both build
processes needs access to this information. For example, at build time the app needs to know the identifier for the
helper tool in order to generate its SMPrivilegedExecutables
entry.
If you configured the build variables to match the sample, then what you specified as the value for the key
HELPER_TOOL_BUNDLE_IDENTIFIER
will be used as the filename for the helper tool.
In the root of the helper tool directory create a Info property list file with a
CFBundleVersion
entry. Unless you want to specify additional entries, you do not need to create a launchd property list as the build
script will do that for you automatically.
The compiler must be told to embed these property lists into the helper tool executable. If you configured the build variables to match the sample your helper tool should have the following build variable configured:
OTHER_LDFLAGS = -sectcreate __TEXT __info_plist $(INFOPLIST_FILE) -sectcreate __TEXT __launchd_plist $(LAUNCHDPLIST_FILE)
Where INFOPLIST_FILE
are LAUNCHDPLIST_FILE
are build variables with values of the paths to the property lists.
Note: At runtime you can read the info property list as you would from an app bundle, but you cannot do so for the launchd property list. Neither of these property lists can be read externally as you would for an app bundle. For this reason, the EmbeddedPropertyList Swift framework was created.
The build script once properly configured will automatically generate these entries for you. The build script relies on many of the build variables mentioned in the "Build Variables" section above. Make sure those are configured first.
Create a folder for build scripts (or use an existing one) and copy PropertyListModifier.swift to it. In order to be
run as a script the file must have its execute bit set. From Terminal, running chmod 755 PropertyListModifier.swift
on
the script will make it world executable.
The following assumes you named that folder "BuildScripts". Now we need to configure your build process to run the script. The instructions below are applicable for Xcode 13 and may differ in future versions.
In your xcodeproj
file:
- Select your app target
- Switch to the Build Phases tab
- Add a Run Script Phase which occurs right after the Dependencies Phase
- Set the command to be run as
"${SRCROOT}"/BuildScripts/PropertyListModifier.swift satisfy-job-bless-requirements
This will add the SMPrivilegedExecutables
entry to your app's Info.plist each time the app is built. If you do not
want this auto-generated value to persist after the build process, take these optional steps:
- Add a Run Script Phase as the last phase
- Set the command to be run as
"${SRCROOT}"/BuildScripts/PropertyListModifier.swift cleanup-job-bless-requirements
This will delete the SMPrivilegedExecutables
entry from your app's Info.plist at the end of the build process. If
this results in an empty Info.plist then the property list will be deleted. The sample is configured to perform this
clean up step.
Next we'll configure the build script to run for the helper tool:
- Select your helper tool target
- Switch to the Build Phases tab
- Add a Run Script Phase which occurs right after the Dependencies Phase
- Set the command to be run as
"${SRCROOT}"/BuildScripts/PropertyListModifier.swift satisfy-job-bless-requirements specify-mach-services auto-increment-version
By specifying "satisfy-job-bless-requirements", the script will add the SMAuthorizedClients
entry to the helper tool's
info property list and the Label
entry to the launchd property list each time the app is built. The code signing
requirement created for the client intentionally goes beyond what is required to satisfy SMJobBless
; specifically it
requires that the app be the same version or greater than the one which installed it. This mitigates downgrade attacks.
If you do not want these entries to be persisted as part of the info or launchd property lists:
- Add a Run Script Phase as the last phase
- Set the command to be run as
"${SRCROOT}"/BuildScripts/PropertyListModifier.swift cleanup-job-bless-requirements cleanup-mach-services
By specifying "auto-increment-version", the script will increment the value of the CFBundleVersion
entry if the source
code has changed. In order for the script to determine if the source code has changed it creates an entry in your helper
tool's info property list with the BuildHash
key and a value equal to the SHA256 hash of the helper tool's source
code. For the version number to continue autoincrementing you'll need to commit these changes. If you do not want this
autoincrement behavior, do not specify auto-increment-version
as an argument for the build script. Note that changes
to framework dependencies will not cause an autoincrement.
Communication between your app and the helper tool should be thought of as a client server relationship. Your app functions as the client and the helper tool as the server. Similarly to communicating with a server over the Internet, your app neither starts nor stops the server. While in theory there are multiple ways for your app to communicate with the helper tool, in practice an XPC Mach service should be used. Note that while this uses XPC for communication, this does not make the helper tool an XPC service.
launchd
will ensure your helper tool is running if it needs to handle a request. If it was not already running when
you made a request, expect a small amount of initial latency.
Apple provides both C and Objective C APIs for XPC communication. Unfortunately, as of macOS 12 the Objective C API does
not provide a publicly documented way to secure the connection. (See
this
Apple Developer Forums discussion on the topic.) Fortunately, since macOS 11 the C API does publicly provide this
functionality and there is an undocumented way to achieve the same result on older versions of macOS. The
SecureXPC framework used by this sample is built on top of the C API and
requires all communication to be secured. It can be automatically configured to use the same code signing requirements
as SMAuthorizedClients
in the helper tool's info property list which is what this sample does. Used in this manner,
the code signing requirement generated by PropertyListModifier.swift
build script mitigates older versions of the app
from communicating with the helper tool.
Note: While XPC allows for sending certain types of live references such as file descriptors, the SecureXPC framework does not support this — it only sends serializable data.
For the helper tool to be an XPC Mach service, it must register to be one in its launchd property list. The build script can do this for you automatically be adding the "specify-mach-services" argument. If you want this to be cleaned up at the end of the build process, then for that Run Script Phase add the "cleanup-mach-services" argument. This sample is configured to do both.
The script will set the service name to be the same as its bundle identifier, which in practice will also be its
filename and the value for Label
. This is done purely for convenience; there is no requirement the service name use
the same identifier. Nowhere does the sample app or helper tool code assume they are the same.
While SecureXPC is designed to restrict which processes your server handle requests from, there's still the possibility
of exploits. For example there could be a vulnerability in your app which gets exploited allowing for arbitrary code
execution. As such it is a best practice to limit what actions your helper tool can do to the absolute bare minimum
required by your app. This way if an exploit exists it limits the damage. In the sample while the helper tool uses
Process
to run executables as root, but it does not honor requests for any arbitrary executable - only those specified
in the AllowedCommand
enum are run.
If you discover your app has an exploit which attackers can use to communicate with your helper tool (and thererefore perform privileged operations) it is of course obvious you should fix the security vulnerability in a future update of your app.
What may be less obvious is it's also important that your app update contain a new version of privileged helper tool and that this helper tool be updated on end users' Macs. The helper tool does not need any code changes, it just needs to be a new version such that it will be accepted as an update.
If there's no code change, why is this needed? This is because while the code has not changed, the SMAuthorizedClients
entry in its info property list will have. Every time the project is built, the PropertyListModifier.swift
build
script includes a requirement that the app have a minimum version of the current version being built. This requirement
is also used by SecureXPC
so this means older versions of the app can't communicate with newer versions of the helper
tool. By updating the helper tool, you'll be preventing attackers from subverting your security fix by running an older
version of your app and then exploiting it as before in order to communicate with the helper tool.
Apple does not provide an API to determine the install status of a helper tool. However, this can still be achieved. See SwiftAuthorizationApp/HelperToolMonitor.swift for an example. There are three different components that make up a helper tool being installed, it is not purely a yes/no situation. For example the helper tool could be registered with launch control (the public interface to launchd) and yet the actual helper tool executable may not exist on disk.
Apple does not provide an API to uninstall the helper tool. Their stated position is, "Users who don’t care won’t notice the leftover helper." Despite not providing an API, it is possible for the helper tool to uninstall itself. See SwiftAuthorizationHelperTool/Uninstaller.swift for an example of how to do so.
SMJobBless
and the equivalent versions offered by the Blessed framework can with user authorization manually update an
installed helper tool. If your app would like to automatically update the helper tool without user involvement see
SwiftAuthorizationHelperTool/Update.swift for an example of how to do so. Note that this updater has certain
self-imposed restrictions and will not perform an update in all circumstances.
The helper tool is run by launchd, not Xcode, and therefore can be challenging to debug. A reliable way to debug it
is to use NSLog
as seen throughout the helper
tool's code. The output of these log statements can be viewed in
Console.app, and it's recommended you filter the output to just
that coming from the helper tool's process. Note that print
statements will not be viewable in Console.app, NSLog
must be used.
If you get the helper tool into a bad state, it may be helpful to uninstall it. You can uninstall the helper tool from
the command line by passing it the uninstall
argument. You will need to use sudo
to do this:
sudo /Library/PrivilegedHelperTools/com.example.SwiftAuthorizationApp.helper uninstall
If the helper tool is sufficiently broken that it can't handle uninstalling itself, you can do so manually:
sudo launchctl unload /Library/LaunchDaemons/com.example.SwiftAuthorizationApp.helper.plist
sudo rm -f /Library/LaunchDaemons/com.example.SwiftAuthorizationApp.helper.plist
sudo rm -f /Library/PrivilegedHelperTools/com.example.SwiftAuthorizationApp.helper
The launchctl unload
command may fail if the helper tool is still running. If so, you can terminate it with Activity
Monitor.
The sample app's architecture and UI are not meant to serve as examples of how to best build a macOS app. Notably the UI technology chosen needed to support back to macOS 10.14.4 which does not have SwiftUI.
While this sample shows one app installing and communicating with one helper tool, the relationship can be many to many. An app can install and communicate with arbitrarily many privileged helper tools. A helper tool could be installed/updated by and communicate with multiple apps.
This sample is inspired by Apple's no longer updated EvenBetterAuthorizationSample written in Objective-C. This sample is implemented exclusively in Swift. While Apple's sample has numerous known security vulnerabilities, this sample has been designed with security in mind. If you discover a security vulnerability, please open an issue!