├── .github └── workflows │ └── swiftlint.yml ├── .gitignore ├── .swiftlint.yml ├── CHANGELOG.md ├── External └── SwiftyCMSDecoder.swift ├── Images ├── Building.png ├── ImportProfile.png ├── SavingSigned.png ├── SavingUnsigned.png ├── UploadSigned.png └── UploadUnsigned.png ├── LICENSE ├── PPPC Utility.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── swiftpm │ └── Package.resolved ├── PPPC UtilityTests ├── Helpers │ ├── ModelBuilder.swift │ └── TCCProfileBuilder.swift ├── Info.plist ├── ModelTests │ ├── ModelTests.swift │ ├── PPPCServicesManagerTests.swift │ └── SemanticVersionTests.swift ├── NetworkingTests │ ├── JamfProAPIClientTests.swift │ ├── NetworkAuthManagerTests.swift │ └── TokenTests.swift └── TCCProfileImporterTests │ ├── TCCProfileImporterTests.swift │ ├── TCCProfileTests.swift │ ├── TestTCCProfileForJamfProAPI.txt │ ├── TestTCCProfileSigned-Broken.mobileconfig │ ├── TestTCCUnsignedProfile-Broken.mobileconfig │ ├── TestTCCUnsignedProfile-Empty.mobileconfig │ ├── TestTCCUnsignedProfile-allLower.mobileconfig │ └── TestTCCUnsignedProfile.mobileconfig ├── README.md ├── Resources ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── PPPC_Logo_128.png │ │ ├── PPPC_Logo_128@2x.png │ │ ├── PPPC_Logo_16.png │ │ ├── PPPC_Logo_16@2x.png │ │ ├── PPPC_Logo_256.png │ │ ├── PPPC_Logo_256@2x.png │ │ ├── PPPC_Logo_32.png │ │ ├── PPPC_Logo_32@2x.png │ │ ├── PPPC_Logo_512.png │ │ └── PPPC_Logo_512@2x.png │ └── Contents.json ├── Base.lproj │ └── Main.storyboard ├── Info.plist ├── PPPC Utility.entitlements └── PPPCServices.json └── Source ├── AppDelegate.swift ├── Extensions ├── ArrayExtensions.swift ├── LoggerExtensions.swift └── TCCProfileExtensions.swift ├── Model ├── AppleEventRule.swift ├── Executable.swift ├── LoadExecutableError.swift ├── Model.swift ├── PPPCServiceInfo.swift ├── PPPCServicesManager.swift ├── SemanticVersion.swift ├── SigningIdentity.swift └── TCCProfile.swift ├── Networking ├── JamfProAPIClient.swift ├── JamfProAPITypes.swift ├── NetworkAuthManager.swift ├── Networking.swift ├── Token.swift ├── URLSessionAsyncCompatibility.swift └── UploadManager.swift ├── SecurityWrapper.swift ├── SwiftUI └── UploadInfoView.swift ├── TCCProfileImporter ├── TCCProfileConfigurationPanel.swift ├── TCCProfileImportError.swift └── TCCProfileImporter.swift ├── View Controllers ├── OpenViewController.swift ├── SaveViewController.swift └── TCCProfileViewController.swift └── Views ├── Alert.swift ├── FlippedClipView.swift └── InfoButton.swift /.github/workflows/swiftlint.yml: -------------------------------------------------------------------------------- 1 | name: SwiftLint 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/swiftlint.yml' 7 | - '.swiftlint.yml' 8 | - '**/*.swift' 9 | 10 | jobs: 11 | SwiftLint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Swiftlint verification 16 | uses: norio-nomura/action-swiftlint@3.2.1 17 | with: 18 | args: --strict 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | .DS_Store 25 | *.swp 26 | 27 | ## Obj-C/Swift specific 28 | *.hmap 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | .build/ 44 | 45 | # CocoaPods 46 | # 47 | # We recommend against adding the Pods directory to your .gitignore. However 48 | # you should judge for yourself, the pros and cons are mentioned at: 49 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 50 | # 51 | # Pods/ 52 | 53 | # Carthage 54 | # 55 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 56 | # Carthage/Checkouts 57 | 58 | Carthage/Build 59 | 60 | # fastlane 61 | # 62 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 63 | # screenshots whenever they are needed. 64 | # For more information about the recommended setup visit: 65 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 66 | 67 | fastlane/report.xml 68 | fastlane/Preview.html 69 | fastlane/screenshots/**/*.png 70 | fastlane/test_output 71 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | opt_in_rules: 2 | - sorted_imports 3 | - trailing_closure 4 | - orphaned_doc_comment 5 | 6 | file_length: 7 | ignore_comment_only_lines: true 8 | 9 | disabled_rules: 10 | - line_length 11 | - type_body_length 12 | - todo 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | 10 | ### Added 11 | - Connection to Jamf Pro can now use client credentials with Jamf Pro v10.49+ ([Issue #120](https://github.com/jamf/PPPC-Utility/issues/120)) [@macblazer](https://github.com/macblazer). 12 | 13 | ### Changed 14 | - Update print and os_log calls to the modern OSLog class calls for updated logging. ([Issue #112](https://github.com/jamf/PPPC-Utility/issues/112)) [@SkylerGodfrey](https://github.com/SkylerGodfrey) 15 | - Now using [Haversack](https://github.com/jamf/Haversack) for simplified access to the keychain ([Issue #124](https://github.com/jamf/PPPC-Utility/issues/124)) [@macblazer](https://github.com/macblazer). 16 | - PPPC Utility now requires macOS 11+ to run. It can still produce profiles usable on older versions of macOS. 17 | 18 | ## [1.5.0] - 2022-10-04 19 | 20 | ### Added 21 | - Help buttons now list related codesigning entitlements ([Issue #105](https://github.com/jamf/PPPC-Utility/issues/105)) [@macblazer](https://github.com/macblazer). 22 | 23 | ### Changed 24 | - Uses token authentication to Jamf Pro API and falls back to Basic authentication if that fails ([Issue #113](https://github.com/jamf/PPPC-Utility/issues/113)) [@macblazer](https://github.com/macblazer). 25 | - Now reads profile keys in a case-insensitive manner during import ([Issue #88](https://github.com/jamf/PPPC-Utility/issues/88)) [@macblazer](https://github.com/macblazer). 26 | - The items in this changelog have been formatted for consistency with links to GitHub issues and contributor profiles. 27 | 28 | 29 | ## [1.4.0] - 2021-08-11 30 | 31 | ### Added 32 | - Changed the property labels to match System Preferences with the MDM key listed in the help ([Issue #79](https://github.com/jamf/PPPC-Utility/issues/79)) [@ty-wilson](https://github.com/ty-wilson). 33 | - Application list and Apple Events app list both support multiple apps being dragged into the list ([Issue #85](https://github.com/jamf/PPPC-Utility/issues/85)) [@macblazer](https://github.com/macblazer). 34 | 35 | ### Fixed 36 | - The code signing label will no longer be truncated ([Issue #54](https://github.com/jamf/PPPC-Utility/issues/54)) [@ty-wilson](https://github.com/ty-wilson). 37 | - Deleting an Apple Event removes the selected item instead of always removing the first one in the list ([Issue #83](https://github.com/jamf/PPPC-Utility/issues/83)) [@ty-wilson](https://github.com/ty-wilson). 38 | 39 | 40 | ## [1.3.0] - 2020-10-22 41 | 42 | ### Added 43 | - Added this Changelog.md file [@hisaac](https://github.com/hisaac). 44 | - The default value on Apple Events is now "Allow" ([Issue #72](https://github.com/jamf/PPPC-Utility/issues/72)) [@ty-wilson](https://github.com/ty-wilson). 45 | - Added support for the new `Authorization` key in Big Sur [@watkyn](https://github.com/watkyn). 46 | - Changed minimum deployment target to macOS 10.15 [@watkyn](https://github.com/watkyn). 47 | 48 | 49 | ## [1.2.1] - 2020-09-17 50 | 51 | Thank you to all the contributors in this release! 52 | 53 | ### Added 54 | - Added swiftlint to the project [@stavares843](https://github.com/stavares843). 55 | - Added a swiftlint GitHub Action [@stavares843](https://github.com/stavares843). 56 | - Added some alerts for better error reporting [@BIG-RAT](https://github.com/BIG-RAT). 57 | 58 | ### Fixed 59 | - Buttons can no longer go off the screen in certain circumstances [@BIG-RAT](https://github.com/BIG-RAT). 60 | - PPPC Utility now properly uploads profiles to Jamf Pro version 10.23 and greater [@kkot](https://github.com/kkot). 61 | 62 | 63 | ## [1.2.0] - 2020-04-29 64 | 65 | ### Added 66 | - Can now import existing profiles from disk [@adku](https://github.com/adku). 67 | 68 | ### Fixed 69 | - TCC properties are ordered alphabetically [@ty-wilson](https://github.com/ty-wilson). 70 | - Duplicate apps can no longer be added to the view [@BIG-RAT](https://github.com/BIG-RAT). 71 | - TCC profile xml properties fixes so profiles can be added to Jamf Now [@pirkla](https://github.com/pirkla). 72 | - Updated labels and placeholders [@pirkla](https://github.com/pirkla). 73 | 74 | 75 | ## [1.1.2] - 2019-10-07 76 | 77 | ### Fixed 78 | - Locally saved profiles are now properly signed if that option is selected while saving. ([Issues #2](https://github.com/jamf/PPPC-Utility/issues/2) and [#25](https://github.com/jamf/PPPC-Utility/issues/25)) [@adku](https://github.com/adku). 79 | 80 | 81 | ## [1.1.1] - 2019-09-20 82 | 83 | ### Fixed 84 | - Updated old app name that was used in PPPC Utility menu. ([Issue #18](https://github.com/jamf/PPPC-Utility/issues/18)) [@adku](https://github.com/adku). 85 | 86 | 87 | ## [1.1.0] - 2019-09-10 88 | 89 | ### Changed 90 | - Updated with the new macOS 10.15 Privacy Preferences Policy Control keys [@mm512](https://github.com/mm512). 91 | - Minor user interface updates [@mm512](https://github.com/mm512). 92 | 93 | 94 | ## [1.0.1] - 2018-10-03 95 | 96 | ### Fixed 97 | - Rules using `SystemPolicySysAdminFiles` are now created correctly ([Issue #4](https://github.com/jamf/PPPC-Utility/issues/4)) [@cyrusingraham](https://github.com/cyrusingraham). 98 | 99 | 100 | ## [1.0.0] - 2018-09-21 101 | 102 | Initial release [@cyrusingraham](https://github.com/cyrusingraham). 103 | 104 | 105 | 106 | [unreleased]: https://github.com/jamf/PPPC-Utility/compare/1.5.0...master 107 | [1.5.0]: https://github.com/jamf/PPPC-Utility/compare/1.4.0...1.5.0 108 | [1.4.0]: https://github.com/jamf/PPPC-Utility/compare/1.3.0...1.4.0 109 | [1.3.0]: https://github.com/jamf/PPPC-Utility/compare/1.2.1...1.3.0 110 | [1.2.1]: https://github.com/jamf/PPPC-Utility/compare/1.2.0...1.2.1 111 | [1.2.0]: https://github.com/jamf/PPPC-Utility/compare/1.1.2...1.2.0 112 | [1.1.2]: https://github.com/jamf/PPPC-Utility/compare/1.1.1...1.1.2 113 | [1.1.1]: https://github.com/jamf/PPPC-Utility/compare/1.1.0...1.1.1 114 | [1.1.0]: https://github.com/jamf/PPPC-Utility/compare/1.0.1...1.1.0 115 | [1.0.1]: https://github.com/jamf/PPPC-Utility/compare/1.0.0...1.0.1 116 | [1.0.0]: https://github.com/jamf/PPPC-Utility/compare/047786dad486e8cc1e159d3f315adb695a566465...1.0.0 117 | -------------------------------------------------------------------------------- /External/SwiftyCMSDecoder.swift: -------------------------------------------------------------------------------- 1 | // SwiftyCMSDecoder.swift 2 | // 3 | // MIT License 4 | // 5 | // Copyright (c) 2018 James Sherlock https://twitter.com/JamesSherlouk/ 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | // 25 | 26 | import Foundation 27 | 28 | /// Source: https://github.com/Sherlouk/SwiftProvisioningProfile 29 | /// Swift wrapper around Apple's Security `CMSDecoder` class 30 | final class SwiftyCMSDecoder { 31 | 32 | var decoder: CMSDecoder 33 | 34 | /// Initialises a new `SwiftyCMSDecoder` which in turn creates a new `CMSDecoder` 35 | init?() { 36 | var newDecoder: CMSDecoder? 37 | CMSDecoderCreate(&newDecoder) 38 | 39 | guard let decoder = newDecoder else { 40 | return nil 41 | } 42 | 43 | self.decoder = decoder 44 | } 45 | 46 | /// Feed raw bytes of the message to be decoded into the decoder. Can be called multiple times. 47 | /// 48 | /// - Parameter data: The raw data you want to have decoded 49 | /// - Returns: Success - `false` upon detection of improperly formatted CMS message. 50 | @discardableResult 51 | func updateMessage(data: NSData) -> Bool { 52 | return CMSDecoderUpdateMessage(decoder, data.bytes, data.length) != errSecUnknownFormat 53 | } 54 | 55 | /// Indicate that no more `updateMessage()` calls are coming; finish decoding the message. 56 | /// 57 | /// - Returns: Success - `false` upon detection of improperly formatted CMS message. 58 | @discardableResult 59 | func finaliseMessage() -> Bool { 60 | return CMSDecoderFinalizeMessage(decoder) != errSecUnknownFormat 61 | } 62 | 63 | /// Obtain the actual message content (payload), if any. If the message was signed with 64 | /// detached content then this will return `nil`. 65 | /// 66 | /// - Warning: This cannot be called until after `finaliseMessage()` is called! 67 | var data: Data? { 68 | var newData: CFData? 69 | CMSDecoderCopyContent(decoder, &newData) 70 | return newData as Data? 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Images/Building.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Images/Building.png -------------------------------------------------------------------------------- /Images/ImportProfile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Images/ImportProfile.png -------------------------------------------------------------------------------- /Images/SavingSigned.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Images/SavingSigned.png -------------------------------------------------------------------------------- /Images/SavingUnsigned.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Images/SavingUnsigned.png -------------------------------------------------------------------------------- /Images/UploadSigned.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Images/UploadSigned.png -------------------------------------------------------------------------------- /Images/UploadUnsigned.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Images/UploadUnsigned.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jamf Software 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PPPC Utility.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /PPPC Utility.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /PPPC Utility.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "haversack", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/jamf/Haversack.git", 7 | "state" : { 8 | "revision" : "840fd566ca709fe0932b231df63833d50488e127", 9 | "version" : "1.1.0" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-collections", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apple/swift-collections", 16 | "state" : { 17 | "revision" : "a902f1823a7ff3c9ab2fba0f992396b948eda307", 18 | "version" : "1.0.5" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /PPPC UtilityTests/Helpers/ModelBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModelBuilder.swift 3 | // 4 | // MIT License 5 | // 6 | // Copyright (c) 2019 Jamf Software 7 | // 8 | // Permission is hereby granted, free of charge, to any person obtaining a copy 9 | // of this software and associated documentation files (the "Software"), to deal 10 | // in the Software without restriction, including without limitation the rights 11 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | // copies of the Software, and to permit persons to whom the Software is 13 | // furnished to do so, subject to the following conditions: 14 | // 15 | // The above copyright notice and this permission notice shall be included in all 16 | // copies or substantial portions of the Software. 17 | // 18 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | // SOFTWARE. 25 | // 26 | 27 | import Cocoa 28 | 29 | @testable import PPPC_Utility 30 | 31 | class ModelBuilder { 32 | 33 | var model: Model 34 | 35 | init() { 36 | model = Model() 37 | } 38 | 39 | func build() -> Model { 40 | return model 41 | } 42 | 43 | func addExecutable(settings: [String: String]) -> ModelBuilder { 44 | let exe = Executable(identifier: "id", codeRequirement: "req", "display") 45 | settings.forEach { key, value in 46 | exe.policy.setValue(value, forKey: key) 47 | } 48 | model.selectedExecutables.append(exe) 49 | 50 | return self 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /PPPC UtilityTests/Helpers/TCCProfileBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TCCProfileBuilder.swift 3 | // 4 | // MIT License 5 | // 6 | // Copyright (c) 2019 Jamf Software 7 | // 8 | // Permission is hereby granted, free of charge, to any person obtaining a copy 9 | // of this software and associated documentation files (the "Software"), to deal 10 | // in the Software without restriction, including without limitation the rights 11 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | // copies of the Software, and to permit persons to whom the Software is 13 | // furnished to do so, subject to the following conditions: 14 | // 15 | // The above copyright notice and this permission notice shall be included in all 16 | // copies or substantial portions of the Software. 17 | // 18 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | // SOFTWARE. 25 | // 26 | 27 | import Cocoa 28 | 29 | @testable import PPPC_Utility 30 | 31 | class TCCProfileBuilder: NSObject { 32 | 33 | // MARK: - build testing objects 34 | 35 | func buildTCCPolicy(allowed: Bool?, authorization: TCCPolicyAuthorizationValue?) -> TCCPolicy { 36 | var policy = TCCPolicy(identifier: "policy id", codeRequirement: "policy code req", 37 | receiverIdentifier: "policy receiver id", receiverCodeRequirement: "policy receiver code req") 38 | policy.comment = "policy comment" 39 | policy.identifierType = "policy id type" 40 | policy.receiverIdentifierType = "policy receiver id type" 41 | policy.allowed = allowed 42 | policy.authorization = authorization 43 | return policy 44 | } 45 | 46 | func buildTCCPolicies(allowed: Bool?, authorization: TCCPolicyAuthorizationValue?) -> [String: [TCCPolicy]] { 47 | return ["SystemPolicyAllFiles": [buildTCCPolicy(allowed: allowed, authorization: authorization)], 48 | "AppleEvents": [buildTCCPolicy(allowed: allowed, authorization: authorization)]] 49 | } 50 | 51 | func buildTCCContent(_ contentIndex: Int, allowed: Bool?, authorization: TCCPolicyAuthorizationValue?) -> TCCProfile.Content { 52 | return TCCProfile.Content(payloadDescription: "Content Desc \(contentIndex)", 53 | displayName: "Content Name \(contentIndex)", 54 | identifier: "Content ID \(contentIndex)", 55 | organization: "Content Org \(contentIndex)", 56 | type: "Content type \(contentIndex)", 57 | uuid: "Content UUID \(contentIndex)", 58 | version: contentIndex, 59 | services: buildTCCPolicies(allowed: allowed, authorization: authorization)) 60 | } 61 | 62 | func buildProfile(allowed: Bool? = nil, authorization: TCCPolicyAuthorizationValue? = nil) -> TCCProfile { 63 | var profile = TCCProfile(organization: "Test Org", 64 | identifier: "Test ID", 65 | displayName: "Test Name", 66 | payloadDescription: "Test Desc", 67 | services: [:]) 68 | profile.content = [buildTCCContent(1, allowed: allowed, authorization: authorization)] 69 | profile.version = 100 70 | profile.uuid = "the uuid" 71 | return profile 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /PPPC UtilityTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /PPPC UtilityTests/ModelTests/PPPCServicesManagerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PPPCServicesManagerTests.swift 3 | // PPPC UtilityTests 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2022 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | import XCTest 30 | 31 | @testable import PPPC_Utility 32 | 33 | class PPPCServicesManagerTests: XCTestCase { 34 | 35 | func testLoadAllServices() { 36 | // given/when 37 | let actual = PPPCServicesManager() 38 | 39 | // then 40 | XCTAssertEqual(actual.allServices.count, 21) 41 | } 42 | 43 | func testUserHelp_withEntitlements() throws { 44 | // given 45 | let services = PPPCServicesManager() 46 | let service = try XCTUnwrap(services.allServices["Camera"]) 47 | 48 | // when 49 | let actual = service.userHelp 50 | 51 | // then 52 | XCTAssertEqual(actual, "Use to deny specified apps access to the camera.\n\nMDM Key: Camera\nRelated entitlements: [\"com.apple.developer.avfoundation.multitasking-camera-access\", \"com.apple.security.device.camera\"]") 53 | } 54 | 55 | func testUserHelp_withoutEntitlements() throws { 56 | // given 57 | let services = PPPCServicesManager() 58 | let service = try XCTUnwrap(services.allServices["ScreenCapture"]) 59 | 60 | // when 61 | let actual = service.userHelp 62 | 63 | // then 64 | XCTAssertEqual(actual, "Deny specified apps access to capture (read) the contents of the system display.\n\nMDM Key: ScreenCapture") 65 | } 66 | 67 | func testCameraIsDenyOnly() throws { 68 | // given 69 | let services = PPPCServicesManager() 70 | let service = try XCTUnwrap(services.allServices["Camera"]) 71 | 72 | // when 73 | let actual = try XCTUnwrap(service.denyOnly) 74 | 75 | // then 76 | XCTAssertTrue(actual) 77 | } 78 | 79 | func testScreenCaptureAllowsStandardUsers() throws { 80 | // given 81 | let services = PPPCServicesManager() 82 | let service = try XCTUnwrap(services.allServices["ScreenCapture"]) 83 | 84 | // when 85 | let actual = try XCTUnwrap(service.allowStandardUsersMacOS11Plus) 86 | 87 | // then 88 | XCTAssertTrue(actual) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /PPPC UtilityTests/ModelTests/SemanticVersionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SemanticVersionTests.swift 3 | // PPPC UtilityTests 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2022 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | @testable import PPPC_Utility 30 | import XCTest 31 | 32 | class SemanticVersionTests: XCTestCase { 33 | func testLessThan() { 34 | // given 35 | let version = SemanticVersion(major: 10, minor: 7, patch: 1) 36 | 37 | // when 38 | let shouldBeLessThan = version < SemanticVersion(major: 10, minor: 7, patch: 4) 39 | let shouldBeLessThan2 = version < SemanticVersion(major: 10, minor: 8, patch: 1) 40 | let shouldBeLessThan3 = version < SemanticVersion(major: 11, minor: 7, patch: 1) 41 | let shouldNotBeLessThan = version < SemanticVersion(major: 10, minor: 7, patch: 1) 42 | let shouldNotBeLessThan2 = version < SemanticVersion(major: 10, minor: 7, patch: 0) 43 | let shouldNotBeLessThan3 = version < SemanticVersion(major: 10, minor: 6, patch: 1) 44 | let shouldNotBeLessThan4 = version < SemanticVersion(major: 9, minor: 7, patch: 1) 45 | 46 | // then 47 | XCTAssertTrue(shouldBeLessThan) 48 | XCTAssertTrue(shouldBeLessThan2) 49 | XCTAssertTrue(shouldBeLessThan3) 50 | XCTAssertFalse(shouldNotBeLessThan) 51 | XCTAssertFalse(shouldNotBeLessThan2) 52 | XCTAssertFalse(shouldNotBeLessThan3) 53 | XCTAssertFalse(shouldNotBeLessThan4) 54 | } 55 | 56 | func testEquality() { 57 | // given 58 | let version = SemanticVersion(major: 10, minor: 7, patch: 1) 59 | 60 | // when 61 | let shouldBeEqual = version == SemanticVersion(major: 10, minor: 7, patch: 1) 62 | let shouldBeNotEqual = version == SemanticVersion(major: 10, minor: 7, patch: 4) 63 | let shouldBeNotEqual2 = version == SemanticVersion(major: 10, minor: 8, patch: 1) 64 | let shouldBeNotEqual3 = version == SemanticVersion(major: 11, minor: 7, patch: 1) 65 | 66 | // then 67 | XCTAssertTrue(shouldBeEqual) 68 | XCTAssertFalse(shouldBeNotEqual) 69 | XCTAssertFalse(shouldBeNotEqual2) 70 | XCTAssertFalse(shouldBeNotEqual3) 71 | } 72 | 73 | func testGreaterThan() { 74 | // given 75 | let version = SemanticVersion(major: 10, minor: 7, patch: 1) 76 | 77 | // when 78 | let shouldNotBeGreaterThan = version > SemanticVersion(major: 10, minor: 7, patch: 4) 79 | let shouldNotBeGreaterThan2 = version > SemanticVersion(major: 10, minor: 8, patch: 1) 80 | let shouldNotBeGreaterThan3 = version > SemanticVersion(major: 11, minor: 7, patch: 1) 81 | let shouldNotBeGreaterThan4 = version > SemanticVersion(major: 10, minor: 7, patch: 1) 82 | let shouldBeGreaterThan = version > SemanticVersion(major: 10, minor: 7, patch: 0) 83 | let shouldBeGreaterThan2 = version > SemanticVersion(major: 10, minor: 6, patch: 1) 84 | let shouldBeGreaterThan3 = version > SemanticVersion(major: 9, minor: 7, patch: 1) 85 | 86 | // then 87 | XCTAssertFalse(shouldNotBeGreaterThan) 88 | XCTAssertFalse(shouldNotBeGreaterThan2) 89 | XCTAssertFalse(shouldNotBeGreaterThan3) 90 | XCTAssertFalse(shouldNotBeGreaterThan4) 91 | XCTAssertTrue(shouldBeGreaterThan) 92 | XCTAssertTrue(shouldBeGreaterThan2) 93 | XCTAssertTrue(shouldBeGreaterThan3) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /PPPC UtilityTests/NetworkingTests/JamfProAPIClientTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JamfProAPIClientTests.swift 3 | // PPPC UtilityTests 4 | // 5 | // SPDX-License-Identifier: MIT 6 | // Copyright (c) 2023 Jamf Software 7 | 8 | import Foundation 9 | import XCTest 10 | 11 | @testable import PPPC_Utility 12 | 13 | class JamfProAPIClientTests: XCTestCase { 14 | func testOAuthTokenRequest() throws { 15 | // given 16 | let authManager = NetworkAuthManager(username: "", password: "") 17 | let apiClient = JamfProAPIClient(serverUrlString: "https://something", tokenManager: authManager) 18 | 19 | // when 20 | let request = try apiClient.oauthTokenRequest(clientId: "mine&yours", clientSecret: "foo bar") 21 | 22 | // then 23 | let body = try XCTUnwrap(request.httpBody) 24 | let bodyString = String(data: body, encoding: .utf8) 25 | XCTAssertEqual(bodyString, "grant_type=client_credentials&client_id=mine%26yours&client_secret=foo%20bar") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /PPPC UtilityTests/NetworkingTests/NetworkAuthManagerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkAuthManagerTests.swift 3 | // PPPC UtilityTests 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2022 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | import XCTest 30 | 31 | @testable import PPPC_Utility 32 | 33 | /// Fake networking class for testing. No actual network was used in the making of this test. 34 | class MockNetworking: Networking { 35 | var errorToThrow: Error? 36 | 37 | init(tokenManager: NetworkAuthManager) { 38 | super.init(serverUrlString: "https://example.com", tokenManager: tokenManager) 39 | } 40 | 41 | override func getBearerToken(authInfo: AuthenticationInfo) async throws -> Token { 42 | if let error = errorToThrow { 43 | throw error 44 | } 45 | 46 | let formatter = ISO8601DateFormatter() 47 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 48 | let expiration = try XCTUnwrap(formatter.date(from: "2950-06-22T22:05:58.81Z")) 49 | 50 | return Token(value: "xyz", expiresAt: expiration) 51 | } 52 | } 53 | 54 | class NetworkAuthManagerTests: XCTestCase { 55 | func testValidToken() async throws { 56 | // given 57 | let authManager = NetworkAuthManager(username: "test", password: "none") 58 | let networking = MockNetworking(tokenManager: authManager) 59 | 60 | // when 61 | let token = try await authManager.validToken(networking: networking) 62 | 63 | // then 64 | XCTAssertEqual(token.value, "xyz") 65 | XCTAssertTrue(token.isValid) 66 | } 67 | 68 | func testValidTokenNetworkFailure() async throws { 69 | // given 70 | let authManager = NetworkAuthManager(username: "test", password: "none") 71 | let networking = MockNetworking(tokenManager: authManager) 72 | networking.errorToThrow = NetworkingError.serverResponse(500, "Bad server") 73 | 74 | // when 75 | do { 76 | _ = try await authManager.validToken(networking: networking) 77 | XCTFail("Expected to throw from `validToken` call") 78 | } catch { 79 | // then 80 | XCTAssertEqual(error as? NetworkingError, NetworkingError.serverResponse(500, "Bad server")) 81 | } 82 | } 83 | 84 | func testValidTokenBearerAuthNotSupported() async throws { 85 | // given 86 | let authManager = NetworkAuthManager(username: "test", password: "none") 87 | let networking = MockNetworking(tokenManager: authManager) 88 | networking.errorToThrow = NetworkingError.serverResponse(404, "No such page") 89 | 90 | // default is that bearer auth is supported. 91 | let firstCheckBearerAuthSupported = await authManager.bearerAuthSupported() 92 | XCTAssertTrue(firstCheckBearerAuthSupported) 93 | 94 | // when 95 | do { 96 | _ = try await authManager.validToken(networking: networking) 97 | XCTFail("Expected to throw from `validToken` call") 98 | } catch { 99 | // then - should throw a `bearerAuthNotSupported` error 100 | XCTAssertEqual(error as? AuthError, AuthError.bearerAuthNotSupported) 101 | } 102 | 103 | // The authManager should now know that bearer auth is not supported 104 | let secondCheckBearerAuthSupported = await authManager.bearerAuthSupported() 105 | XCTAssertFalse(secondCheckBearerAuthSupported) 106 | } 107 | 108 | func testValidTokenInvalidUsernamePassword() async throws { 109 | // given 110 | let authManager = NetworkAuthManager(username: "test", password: "none") 111 | let networking = MockNetworking(tokenManager: authManager) 112 | networking.errorToThrow = NetworkingError.serverResponse(401, "Not authorized") 113 | 114 | // when 115 | do { 116 | _ = try await authManager.validToken(networking: networking) 117 | XCTFail("Expected to throw from `validToken` call") 118 | } catch { 119 | // then - should throw a `invalidUsernamePassword` error 120 | XCTAssertEqual(error as? AuthError, AuthError.invalidUsernamePassword) 121 | } 122 | } 123 | 124 | func testBasicAuthString() throws { 125 | // given 126 | let authManager = NetworkAuthManager(username: "test", password: "none") 127 | 128 | // when 129 | let actual = try authManager.basicAuthString() 130 | 131 | // then 132 | XCTAssertEqual(actual, "dGVzdDpub25l") 133 | } 134 | 135 | func testBasicAuthStringEmptyUsername() throws { 136 | // given 137 | let authManager = NetworkAuthManager(username: "", password: "none") 138 | 139 | // when 140 | do { 141 | _ = try authManager.basicAuthString() 142 | XCTFail("Expected to throw from `basicAuthString` call") 143 | } catch { 144 | // then - should throw a `invalidUsernamePassword` error 145 | XCTAssertEqual(error as? AuthError, AuthError.invalidUsernamePassword) 146 | } 147 | } 148 | 149 | func testBasicAuthStringEmptyPassword() throws { 150 | // given 151 | let authManager = NetworkAuthManager(username: "mine", password: "") 152 | 153 | // when 154 | do { 155 | _ = try authManager.basicAuthString() 156 | XCTFail("Expected to throw from `basicAuthString` call") 157 | } catch { 158 | // then - should throw a `invalidUsernamePassword` error 159 | XCTAssertEqual(error as? AuthError, AuthError.invalidUsernamePassword) 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /PPPC UtilityTests/NetworkingTests/TokenTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TokenTests.swift 3 | // PPPC UtilityTests 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2022 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | import XCTest 30 | 31 | @testable import PPPC_Utility 32 | 33 | class TokenTests: XCTestCase { 34 | func testPastIsNotValid() throws { 35 | // given 36 | let formatter = ISO8601DateFormatter() 37 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 38 | let expiration = try XCTUnwrap(formatter.date(from: "2021-06-22T22:05:58.81Z")) 39 | let token = Token(value: "abc", expiresAt: expiration) 40 | 41 | // when 42 | let valid = token.isValid 43 | 44 | // then 45 | XCTAssertFalse(valid) 46 | } 47 | 48 | func testFutureIsValid() throws { 49 | // given 50 | let formatter = ISO8601DateFormatter() 51 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 52 | let expiration = try XCTUnwrap(formatter.date(from: "2750-06-22T22:05:58.81Z")) 53 | let token = Token(value: "abc", expiresAt: expiration) 54 | 55 | // when 56 | let valid = token.isValid 57 | 58 | // then 59 | XCTAssertTrue(valid) 60 | } 61 | 62 | // MARK: - Decoding 63 | 64 | func testDecodeBasicAuthToken() throws { 65 | // given 66 | let jsonText = """ 67 | { 68 | "token": "abc", 69 | "expires": "2750-06-22T22:05:58.81Z" 70 | } 71 | """ 72 | let jsonData = try XCTUnwrap(jsonText.data(using: .utf8)) 73 | let decoder = JSONDecoder() 74 | 75 | // when 76 | let actual = try decoder.decode(Token.self, from: jsonData) 77 | 78 | // then 79 | XCTAssertEqual(actual.value, "abc") 80 | XCTAssertNotNil(actual.expiresAt) 81 | XCTAssertTrue(actual.isValid) 82 | } 83 | 84 | func testDecodeExpiredBasicAuthToken() throws { 85 | // given 86 | let jsonText = """ 87 | { 88 | "token": "abc", 89 | "expires": "1970-10-24T22:05:58.81Z" 90 | } 91 | """ 92 | let jsonData = try XCTUnwrap(jsonText.data(using: .utf8)) 93 | let decoder = JSONDecoder() 94 | 95 | // when 96 | let actual = try decoder.decode(Token.self, from: jsonData) 97 | 98 | // then 99 | XCTAssertEqual(actual.value, "abc") 100 | XCTAssertNotNil(actual.expiresAt) 101 | XCTAssertFalse(actual.isValid) 102 | } 103 | 104 | func testDecodeClientCredentialsAuthToken() throws { 105 | // given 106 | let jsonText = """ 107 | { 108 | "access_token": "abc", 109 | "scope": "api-role:2", 110 | "token_type": "Bearer", 111 | "expires_in": 599 112 | } 113 | """ 114 | let jsonData = try XCTUnwrap(jsonText.data(using: .utf8)) 115 | let decoder = JSONDecoder() 116 | 117 | // when 118 | let actual = try decoder.decode(Token.self, from: jsonData) 119 | 120 | // then 121 | XCTAssertEqual(actual.value, "abc") 122 | XCTAssertNotNil(actual.expiresAt) 123 | XCTAssertTrue(actual.isValid) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /PPPC UtilityTests/TCCProfileImporterTests/TCCProfileImporterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TCCProfileImporterTests.swift 3 | // PPPC UtilityTests 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2019 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | import XCTest 30 | 31 | @testable import PPPC_Utility 32 | 33 | class TCCProfileImporterTests: XCTestCase { 34 | 35 | func testBrokenSignedTCCProfile() { 36 | let tccProfileImporter = TCCProfileImporter() 37 | 38 | let resourceURL = getResourceProfile(fileName: "TestTCCProfileSigned-Broken") 39 | 40 | tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) { tccProfileResult in 41 | switch tccProfileResult { 42 | case .success: 43 | XCTFail("Broken Signed Profile, it shouldn't be success") 44 | case .failure(let tccProfileError): 45 | XCTAssertTrue(tccProfileError.localizedDescription.contains("The given data was not a valid property list.")) 46 | } 47 | } 48 | } 49 | 50 | func testEmptyContentTCCProfile() { 51 | let tccProfileImporter = TCCProfileImporter() 52 | 53 | let resourceURL = getResourceProfile(fileName: "TestTCCUnsignedProfile-Empty") 54 | 55 | let expectedTCCProfileError = TCCProfileImportError.invalidProfileFile(description: "PayloadContent") 56 | 57 | tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) { tccProfileResult in 58 | switch tccProfileResult { 59 | case .success: 60 | XCTFail("Empty Content, it shouldn't be success") 61 | case .failure(let tccProfileError): 62 | XCTAssertEqual(tccProfileError.localizedDescription, expectedTCCProfileError.localizedDescription) 63 | } 64 | } 65 | } 66 | 67 | func testCorrectUnsignedProfileContentData() { 68 | let tccProfileImporter = TCCProfileImporter() 69 | 70 | let resourceURL = getResourceProfile(fileName: "TestTCCUnsignedProfile") 71 | 72 | tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) { tccProfileResult in 73 | switch tccProfileResult { 74 | case .success(let tccProfile): 75 | XCTAssertNotNil(tccProfile.content) 76 | XCTAssertNotNil(tccProfile.content[0].services) 77 | case .failure(let tccProfileError): 78 | XCTFail("Unable to read tccProfile \(tccProfileError.localizedDescription)") 79 | } 80 | } 81 | } 82 | 83 | func testCorrectUnsignedProfileContentDataAllLowercase() { 84 | let tccProfileImporter = TCCProfileImporter() 85 | 86 | let resourceURL = getResourceProfile(fileName: "TestTCCUnsignedProfile-allLower") 87 | 88 | tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) { tccProfileResult in 89 | switch tccProfileResult { 90 | case .success(let tccProfile): 91 | XCTAssertNotNil(tccProfile.content) 92 | XCTAssertNotNil(tccProfile.content[0].services) 93 | case .failure(let tccProfileError): 94 | XCTFail("Unable to read tccProfile \(tccProfileError.localizedDescription)") 95 | } 96 | } 97 | } 98 | 99 | func testBrokenUnsignedProfile() { 100 | let tccProfileImporter = TCCProfileImporter() 101 | 102 | let resourceURL = getResourceProfile(fileName: "TestTCCUnsignedProfile-Broken") 103 | 104 | let expectedTCCProfileError = TCCProfileImportError.invalidProfileFile(description: "The given data was not a valid property list.") 105 | 106 | tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) { tccProfileResult in 107 | switch tccProfileResult { 108 | case .success: 109 | XCTFail("Broken Unsigned Profile, it shouldn't be success") 110 | case .failure(let tccProfileError): 111 | XCTAssertEqual(tccProfileError.localizedDescription, expectedTCCProfileError.localizedDescription) 112 | } 113 | } 114 | } 115 | 116 | private func getResourceProfile(fileName: String) -> URL { 117 | let testBundle = Bundle(for: type(of: self)) 118 | guard let resourceURL = testBundle.url(forResource: fileName, withExtension: "mobileconfig") else { 119 | XCTFail("Resource file should exists") 120 | return URL(fileURLWithPath: "invalidPath") 121 | } 122 | return resourceURL 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /PPPC UtilityTests/TCCProfileImporterTests/TCCProfileTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TCCProfileTests.swift 3 | // 4 | // MIT License 5 | // 6 | // Copyright (c) 2019 Jamf Software 7 | // 8 | // Permission is hereby granted, free of charge, to any person obtaining a copy 9 | // of this software and associated documentation files (the "Software"), to deal 10 | // in the Software without restriction, including without limitation the rights 11 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | // copies of the Software, and to permit persons to whom the Software is 13 | // furnished to do so, subject to the following conditions: 14 | // 15 | // The above copyright notice and this permission notice shall be included in all 16 | // copies or substantial portions of the Software. 17 | // 18 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | // SOFTWARE. 25 | // 26 | import XCTest 27 | 28 | @testable import PPPC_Utility 29 | 30 | class TCCProfileTests: XCTestCase { 31 | 32 | // MARK: - tests for serializing to and from xml 33 | 34 | func testSerializationOfComplexProfileUsingAuthorization() throws { 35 | // when we export to xml and reimport it should still have the same attributes 36 | let plistData = try TCCProfileBuilder().buildProfile(authorization: .allowStandardUserToSetSystemService).xmlData() 37 | let profile = try TCCProfile.parse(from: plistData) 38 | 39 | // then verify the config profile top level 40 | XCTAssertEqual("Configuration", profile.type) 41 | XCTAssertEqual(100, profile.version) 42 | XCTAssertEqual("the uuid", profile.uuid) 43 | XCTAssertEqual("System", profile.scope) 44 | XCTAssertEqual("Test Org", profile.organization) 45 | XCTAssertEqual("Test ID", profile.identifier) 46 | XCTAssertEqual("Test Name", profile.displayName) 47 | XCTAssertEqual("Test Desc", profile.payloadDescription) 48 | 49 | // then verify the payload content top level 50 | XCTAssertEqual(1, profile.content.count) 51 | profile.content.forEach { content in 52 | XCTAssertEqual("Content Desc 1", content.payloadDescription) 53 | XCTAssertEqual("Content Name 1", content.displayName) 54 | XCTAssertEqual("Content ID 1", content.identifier) 55 | XCTAssertEqual("Content Org 1", content.organization) 56 | XCTAssertEqual("Content type 1", content.type) 57 | XCTAssertEqual("Content UUID 1", content.uuid) 58 | XCTAssertEqual(1, content.version) 59 | 60 | // then verify the services key 61 | XCTAssertEqual(2, content.services.count) 62 | let allFiles = content.services["SystemPolicyAllFiles"] 63 | XCTAssertEqual(1, allFiles?.count) 64 | allFiles?.forEach { policy in 65 | XCTAssertEqual("policy id", policy.identifier) 66 | XCTAssertEqual("policy id type", policy.identifierType) 67 | XCTAssertEqual("policy code req", policy.codeRequirement) 68 | XCTAssertNil(policy.allowed) 69 | XCTAssertEqual(TCCPolicyAuthorizationValue.allowStandardUserToSetSystemService, policy.authorization) 70 | XCTAssertEqual("policy comment", policy.comment) 71 | XCTAssertEqual("policy receiver id", policy.receiverIdentifier) 72 | XCTAssertEqual("policy receiver id type", policy.receiverIdentifierType) 73 | XCTAssertEqual("policy receiver code req", policy.receiverCodeRequirement) 74 | } 75 | } 76 | } 77 | 78 | func testSerializationOfProfileUsingLegacyAllowedKey() throws { 79 | // when we export to xml and reimport it should still have the same attributes 80 | let plistData = try TCCProfileBuilder().buildProfile(allowed: true).xmlData() 81 | let profile = try TCCProfile.parse(from: plistData) 82 | 83 | // then verify the config profile top level 84 | XCTAssertEqual("Configuration", profile.type) 85 | XCTAssertEqual(100, profile.version) 86 | XCTAssertEqual("the uuid", profile.uuid) 87 | XCTAssertEqual("System", profile.scope) 88 | XCTAssertEqual("Test Org", profile.organization) 89 | XCTAssertEqual("Test ID", profile.identifier) 90 | XCTAssertEqual("Test Name", profile.displayName) 91 | XCTAssertEqual("Test Desc", profile.payloadDescription) 92 | 93 | // then verify the payload content top level 94 | XCTAssertEqual(1, profile.content.count) 95 | profile.content.forEach { content in 96 | XCTAssertEqual("Content Desc 1", content.payloadDescription) 97 | XCTAssertEqual("Content Name 1", content.displayName) 98 | XCTAssertEqual("Content ID 1", content.identifier) 99 | XCTAssertEqual("Content Org 1", content.organization) 100 | XCTAssertEqual("Content type 1", content.type) 101 | XCTAssertEqual("Content UUID 1", content.uuid) 102 | XCTAssertEqual(1, content.version) 103 | 104 | // then verify the services key 105 | XCTAssertEqual(2, content.services.count) 106 | let allFiles = content.services["SystemPolicyAllFiles"] 107 | XCTAssertEqual(1, allFiles?.count) 108 | allFiles?.forEach { policy in 109 | XCTAssertEqual("policy id", policy.identifier) 110 | XCTAssertEqual("policy id type", policy.identifierType) 111 | XCTAssertEqual("policy code req", policy.codeRequirement) 112 | XCTAssertEqual(true, policy.allowed) 113 | XCTAssertNil(policy.authorization) 114 | XCTAssertEqual("policy comment", policy.comment) 115 | XCTAssertEqual("policy receiver id", policy.receiverIdentifier) 116 | XCTAssertEqual("policy receiver id type", policy.receiverIdentifierType) 117 | XCTAssertEqual("policy receiver code req", policy.receiverCodeRequirement) 118 | } 119 | } 120 | } 121 | 122 | func testSerializationOfProfileWhenBothAllowedAndAuthorizationUsed() throws { 123 | // when we export to xml and reimport it should still have the same attributes 124 | let plistData = try TCCProfileBuilder().buildProfile(allowed: false, authorization: .allow).xmlData() 125 | let profile = try TCCProfile.parse(from: plistData) 126 | 127 | // then verify the config profile top level 128 | XCTAssertEqual("Configuration", profile.type) 129 | 130 | // then verify the payload content top level 131 | XCTAssertEqual(1, profile.content.count) 132 | profile.content.forEach { content in 133 | XCTAssertEqual("Content UUID 1", content.uuid) 134 | XCTAssertEqual(1, content.version) 135 | 136 | // then verify the services key 137 | XCTAssertEqual(2, content.services.count) 138 | let allFiles = content.services["SystemPolicyAllFiles"] 139 | XCTAssertEqual(1, allFiles?.count) 140 | allFiles?.forEach { policy in 141 | XCTAssertEqual(false, policy.allowed) 142 | XCTAssertEqual(policy.authorization, TCCPolicyAuthorizationValue.allow) 143 | } 144 | } 145 | } 146 | 147 | // unit tests for handling both Auth and allowed keys should fail? 148 | 149 | func testSettingLegacyAllowValueNullifiesAuthorization() throws { 150 | // given 151 | var tccPolicy = TCCPolicy(identifier: "id", codeRequirement: "req", receiverIdentifier: "recId", receiverCodeRequirement: "recreq") 152 | tccPolicy.authorization = .allow 153 | 154 | // when 155 | tccPolicy.allowed = true 156 | 157 | // then 158 | XCTAssertNil(tccPolicy.authorization) 159 | XCTAssertTrue(try XCTUnwrap(tccPolicy.allowed)) 160 | } 161 | 162 | func testSettingAuthorizationValueDoesNotNullifyAllowed() { 163 | // given 164 | var tccPolicy = TCCPolicy(identifier: "id", codeRequirement: "req", receiverIdentifier: "recId", receiverCodeRequirement: "recreq") 165 | tccPolicy.allowed = false 166 | 167 | // when 168 | tccPolicy.authorization = .allowStandardUserToSetSystemService 169 | 170 | // then 171 | XCTAssertEqual(tccPolicy.allowed, false, "we don't have to nil this out because we use authorization by default if present") 172 | XCTAssertEqual(tccPolicy.authorization, TCCPolicyAuthorizationValue.allowStandardUserToSetSystemService) 173 | } 174 | 175 | func testJamfProAPIData() throws { 176 | // given - build the test profile 177 | let tccProfile = TCCProfileBuilder().buildProfile(allowed: false, authorization: .allow) 178 | let expected = try loadTextFile(fileName: "TestTCCProfileForJamfProAPI").trimmingCharacters(in: .whitespacesAndNewlines) 179 | 180 | // when - wrap in Jamf Pro API xml 181 | let data = try tccProfile.jamfProAPIData(signingIdentity: nil, site: nil) 182 | 183 | // then 184 | let xmlString = String(data: data, encoding: .utf8) 185 | XCTAssertEqual(xmlString, expected) 186 | } 187 | 188 | private func loadTextFile(fileName: String) throws -> String { 189 | let testBundle = Bundle(for: type(of: self)) 190 | guard let resourceURL = testBundle.url(forResource: fileName, withExtension: "txt") else { 191 | XCTFail("Resource file should exists") 192 | return "" 193 | } 194 | return try String(contentsOf: resourceURL) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /PPPC UtilityTests/TCCProfileImporterTests/TestTCCProfileForJamfProAPI.txt: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 | <plist version="1.0"> 4 | <dict> 5 | <key>PayloadContent</key> 6 | <array> 7 | <dict> 8 | <key>PayloadDescription</key> 9 | <string>Content Desc 1</string> 10 | <key>PayloadDisplayName</key> 11 | <string>Content Name 1</string> 12 | <key>PayloadIdentifier</key> 13 | <string>Content ID 1</string> 14 | <key>PayloadOrganization</key> 15 | <string>Content Org 1</string> 16 | <key>PayloadType</key> 17 | <string>Content type 1</string> 18 | <key>PayloadUUID</key> 19 | <string>Content UUID 1</string> 20 | <key>PayloadVersion</key> 21 | <integer>1</integer> 22 | <key>Services</key> 23 | <dict> 24 | <key>AppleEvents</key> 25 | <array> 26 | <dict> 27 | <key>AEReceiverCodeRequirement</key> 28 | <string>policy receiver code req</string> 29 | <key>AEReceiverIdentifier</key> 30 | <string>policy receiver id</string> 31 | <key>AEReceiverIdentifierType</key> 32 | <string>policy receiver id type</string> 33 | <key>Allowed</key> 34 | <false/> 35 | <key>Authorization</key> 36 | <string>Allow</string> 37 | <key>CodeRequirement</key> 38 | <string>policy code req</string> 39 | <key>Comment</key> 40 | <string>policy comment</string> 41 | <key>Identifier</key> 42 | <string>policy id</string> 43 | <key>IdentifierType</key> 44 | <string>policy id type</string> 45 | </dict> 46 | </array> 47 | <key>SystemPolicyAllFiles</key> 48 | <array> 49 | <dict> 50 | <key>AEReceiverCodeRequirement</key> 51 | <string>policy receiver code req</string> 52 | <key>AEReceiverIdentifier</key> 53 | <string>policy receiver id</string> 54 | <key>AEReceiverIdentifierType</key> 55 | <string>policy receiver id type</string> 56 | <key>Allowed</key> 57 | <false/> 58 | <key>Authorization</key> 59 | <string>Allow</string> 60 | <key>CodeRequirement</key> 61 | <string>policy code req</string> 62 | <key>Comment</key> 63 | <string>policy comment</string> 64 | <key>Identifier</key> 65 | <string>policy id</string> 66 | <key>IdentifierType</key> 67 | <string>policy id type</string> 68 | </dict> 69 | </array> 70 | </dict> 71 | </dict> 72 | </array> 73 | <key>PayloadDescription</key> 74 | <string>Test Desc</string> 75 | <key>PayloadDisplayName</key> 76 | <string>Test Name</string> 77 | <key>PayloadIdentifier</key> 78 | <string>Test ID</string> 79 | <key>PayloadOrganization</key> 80 | <string>Test Org</string> 81 | <key>PayloadScope</key> 82 | <string>System</string> 83 | <key>PayloadType</key> 84 | <string>Configuration</string> 85 | <key>PayloadUUID</key> 86 | <string>the uuid</string> 87 | <key>PayloadVersion</key> 88 | <integer>100</integer> 89 | </dict> 90 | </plist> 91 | Test NameTest Desc -------------------------------------------------------------------------------- /PPPC UtilityTests/TCCProfileImporterTests/TestTCCProfileSigned-Broken.mobileconfig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/PPPC UtilityTests/TCCProfileImporterTests/TestTCCProfileSigned-Broken.mobileconfig -------------------------------------------------------------------------------- /PPPC UtilityTests/TCCProfileImporterTests/TestTCCUnsignedProfile-Broken.mobileconfig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PayloadDescription 6 | Test Unsigned Profile Empty 7 | PayloadDisplayName 8 | TestUnsignedProfile Empty 9 | PayloadIdentifier 10 | 3A4EDE0C-A189-4372-953F-304ECA0B6489 11 | PayloadOrganization 12 | Jamf 13 | PayloadType 14 | com.apple.TCC.configuration-profile-policy 15 | PayloadUUID 16 | 3FF5028C-CAA0-4B2F-A7BF-2A3539074424 17 | PayloadVersion 18 | 1 19 | PayloadScope 20 | system 21 | 22 | -------------------------------------------------------------------------------- /PPPC UtilityTests/TCCProfileImporterTests/TestTCCUnsignedProfile-Empty.mobileconfig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PayloadDescription 6 | Test Unsigned Profile Empty 7 | PayloadDisplayName 8 | TestUnsignedProfile Empty 9 | PayloadIdentifier 10 | 3A4EDE0C-A189-4372-953F-304ECA0B6489 11 | PayloadOrganization 12 | Jamf 13 | PayloadType 14 | com.apple.TCC.configuration-profile-policy 15 | PayloadUUID 16 | 3FF5028C-CAA0-4B2F-A7BF-2A3539074424 17 | PayloadVersion 18 | 1 19 | PayloadScope 20 | system 21 | 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![PPPC Utility logo][logo] Privacy Preferences Policy Control (PPPC) Utility 2 | 3 | [logo]: /Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_32%402x.png "PPPC Utility" 4 | 5 | PPPC Utility is a macOS (10.15 and newer) application for creating configuration profiles containing the Privacy Preferences Policy Control payload for macOS. The profiles can be saved locally, signed or unsigned. Profiles can also be uploaded directly to a Jamf Pro server. 6 | 7 | All changes to the application are tracked in [the changelog](https://github.com/jamf/PPPC-Utility/blob/master/CHANGELOG.md). 8 | 9 | ## Installation 10 | 11 | Download the latest version from [the Releases page](https://github.com/jamf/PPPC-Utility/releases). 12 | 13 | ## Building profile 14 | 15 | Start by adding the bundles/executables for the payload by using drag-and-drop or by selecting the add (+) button in the left corner. 16 | 17 | ![Start by adding to the **Applications** table](/Images/Building.png "Building profile") 18 | 19 | ## Saving 20 | 21 | Profiles can be saved locally either signed or unsigned. 22 | 23 | ![Click **Save** button to save a profile](/Images/SavingUnsigned.png "Saving an unsigned profile") 24 | 25 | ![Choose a **Signing Identity** to save a signed profile](/Images/SavingSigned.png "Saving a signed profile") 26 | 27 | ## Upload to Jamf Pro 28 | 29 | PPPC Utility can use bearer token authentication (or basic authentication as a fallback for versions of Jamf Pro older than v10.34) to any supported 30 | Jamf Pro version using the username and password of a Jamf Pro user account. The user account at minimum needs the two privileges indicated below. 31 | 32 | Jamf Pro 10.49 and higher can use OAuth client credentials to access the API. The client ID and client secret generated by Jamf Pro in the 33 | "API Roles and clients" settings are used during the PPPC Utility upload process. When setting up the API Role, these are the permissions that 34 | PPPC Utility requires to upload the profiles. 35 | 36 | #### Required API Permissions 37 | 38 | - "Create macOS Configuration Profiles" - primary permission to upload profiles; each upload from PPPC Utility creates a new profile. 39 | - "Read Activation Code" - needed to retrieve the organization name that is placed in the profile. 40 | 41 | ### Jamf Pro 10.7.1 and newer 42 | 43 | Starting in Jamf Pro 10.7.1 the Privacy Preferences Policy Control Payload can be uploaded to the API without being signed before uploading. 44 | 45 | ![In 10.7.1 or greater choosing **Signing Identity** is optional before upload](/Images/UploadUnsigned.png "Upload unsigned") 46 | 47 | ### Jamf Pro 10.7.0 and below 48 | 49 | To upload the Privacy Preferences Policy Control Payload to Jamf Pro 10.7.0 and below, the profile will need to be signed before uploading. 50 | 51 | ![In 10.7.0 or less **Signing Identity** must be chosen before uploading](/Images/UploadSigned.png "Upload signed") 52 | 53 | ## Importing 54 | 55 | Signed and unsigned profiles can be imported. 56 | 57 | ![Import any profile](/Images/ImportProfile.png "Import profiles") 58 | -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "PPPC_Logo_16.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "PPPC_Logo_16@2x.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "PPPC_Logo_32.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "PPPC_Logo_32@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "PPPC_Logo_128.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "PPPC_Logo_128@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "PPPC_Logo_256.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "PPPC_Logo_256@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "PPPC_Logo_512.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "PPPC_Logo_512@2x.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_128.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_128@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_16.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_16@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_256.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_256@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_32.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_32@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_512.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/PPPC-Utility/82249b9ce054342c671cf9956dba0a95e6b4bfb6/Resources/Assets.xcassets/AppIcon.appiconset/PPPC_Logo_512@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | 3 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSHumanReadableCopyright 26 | Copyright © 2018 Jamf. All rights reserved. 27 | NSMainStoryboardFile 28 | Main 29 | NSPrincipalClass 30 | NSApplication 31 | 32 | 33 | -------------------------------------------------------------------------------- /Resources/PPPC Utility.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Resources/PPPCServices.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "mdmKey": "Accessibility", 4 | "englishName": "Accessibility", 5 | "englishDescription": "Allows specified apps to control the Mac via Accessibility APIs." 6 | }, 7 | { 8 | "mdmKey": "AppleEvents", 9 | "englishName": "AppleEvents", 10 | "englishDescription": "Allows specified apps to send a restricted AppleEvent to another process." 11 | }, 12 | { 13 | "mdmKey": "Calendar", 14 | "englishName": "Calendars", 15 | "englishDescription": "Allows specified apps access to event information managed by Calendar.", 16 | "entitlements": 17 | [ 18 | "com.apple.security.personal-information.calendars" 19 | ] 20 | }, 21 | { 22 | "mdmKey": "Camera", 23 | "englishName": "Camera", 24 | "englishDescription": "Use to deny specified apps access to the camera.", 25 | "entitlements": 26 | [ 27 | "com.apple.developer.avfoundation.multitasking-camera-access", 28 | "com.apple.security.device.camera" 29 | ], 30 | "denyOnly": true 31 | }, 32 | { 33 | "mdmKey": "AddressBook", 34 | "englishName": "Contacts", 35 | "englishDescription": "Allows specified apps access to contact information managed by Contacts.", 36 | "entitlements": 37 | [ 38 | "com.apple.developer.contacts.notes", 39 | "com.apple.security.personal-information.addressbook" 40 | ] 41 | }, 42 | { 43 | "mdmKey": "SystemPolicyDesktopFolder", 44 | "englishName": "Desktop Folder", 45 | "englishDescription": "Allows specified apps access to the Desktop folder.", 46 | "entitlements": 47 | [ 48 | "com.apple.security.files.user-selected.read-only", 49 | "com.apple.security.files.user-selected.read-write" 50 | ] 51 | }, 52 | { 53 | "mdmKey": "SystemPolicyDocumentsFolder", 54 | "englishName": "Documents Folder", 55 | "englishDescription": "Allows specified apps access to the Documents folder.", 56 | "entitlements": 57 | [ 58 | "com.apple.security.files.user-selected.read-only", 59 | "com.apple.security.files.user-selected.read-write" 60 | ] 61 | }, 62 | { 63 | "mdmKey": "SystemPolicyDownloadsFolder", 64 | "englishName": "Downloads Folder", 65 | "englishDescription": "Allows specified apps access to the Downloads folder.", 66 | "entitlements": 67 | [ 68 | "com.apple.security.files.downloads.read-only", 69 | "com.apple.security.files.downloads.read-write", 70 | "com.apple.security.files.user-selected.read-only", 71 | "com.apple.security.files.user-selected.read-write" 72 | ] 73 | }, 74 | { 75 | "mdmKey": "FileProviderPresence", 76 | "englishName": "File Provider presence", 77 | "englishDescription": "Allows specified File Provider apps access to know when the user is using files managed by the File Provider.", 78 | "entitlements": 79 | [ 80 | "com.apple.developer.fileprovider.testing-mode" 81 | ] 82 | }, 83 | { 84 | "mdmKey": "ListenEvent", 85 | "englishName": "Input Monitoring", 86 | "englishDescription": "Set which approved apps have specified access to input devices (mouse, keyboard, trackpad).", 87 | "entitlements": 88 | [ 89 | "com.apple.security.device.usb", 90 | "com.apple.vm.device-access" 91 | ], 92 | "denyOnly": true, 93 | "allowStandardUsersMacOS11Plus": true 94 | }, 95 | { 96 | "mdmKey": "MediaLibrary", 97 | "englishName": "Media & Apple Music", 98 | "englishDescription": "Allows specified apps access to access Apple Music, music and video activity, and the media library.", 99 | "entitlements": 100 | [ 101 | "com.apple.security.assets.music.read-only", 102 | "com.apple.security.assets.music.read-write", 103 | "com.apple.security.files.user-selected.read-only", 104 | "com.apple.security.files.user-selected.read-write", 105 | "com.apple.security.assets.movies.read-only", 106 | "com.apple.security.assets.movies.read-write" 107 | ] 108 | }, 109 | { 110 | "mdmKey": "Microphone", 111 | "englishName": "Microphone", 112 | "englishDescription": "Deny specified apps access to the microphone.", 113 | "entitlements": 114 | [ 115 | "com.apple.security.device.microphone" 116 | ], 117 | "denyOnly": true 118 | }, 119 | { 120 | "mdmKey": "SystemPolicyNetworkVolumes", 121 | "englishName": "Network Volumes", 122 | "englishDescription": "Allows specified apps access to files on network volumes.", 123 | "entitlements": 124 | [ 125 | "com.apple.security.files.user-selected.read-only", 126 | "com.apple.security.files.user-selected.read-write" 127 | ] 128 | }, 129 | { 130 | "mdmKey": "Photos", 131 | "englishName": "Photos", 132 | "englishDescription": "Allows specified apps access to images managed by the Photos app in: /Users/username/Pictures/Photos Library Note: If the user put their photo library somewhere else, it won’t be protected from apps.", 133 | "entitlements": 134 | [ 135 | "com.apple.security.assets.pictures.read-only", 136 | "com.apple.security.assets.pictures.read-write", 137 | "com.apple.security.files.user-selected.read-only", 138 | "com.apple.security.files.user-selected.read-write" 139 | ] 140 | }, 141 | { 142 | "mdmKey": "PostEvent", 143 | "englishName": "Post Event", 144 | "englishDescription": "Allows specified apps to use CoreGraphics APIs to send CGEvents to the system event stream." 145 | }, 146 | { 147 | "mdmKey": "Reminders", 148 | "englishName": "Reminders", 149 | "englishDescription": "Allows specified apps access to information managed by Reminders." 150 | }, 151 | { 152 | "mdmKey": "SystemPolicyRemovableVolumes", 153 | "englishName": "Removable Volumes", 154 | "englishDescription": "Allows specified apps access to files on removable volumes.", 155 | "entitlements": 156 | [ 157 | "com.apple.security.files.user-selected.read-only", 158 | "com.apple.security.files.user-selected.read-write" 159 | ] 160 | }, 161 | { 162 | "mdmKey": "ScreenCapture", 163 | "englishName": "Screen Recording", 164 | "englishDescription": "Deny specified apps access to capture (read) the contents of the system display.", 165 | "denyOnly": true, 166 | "allowStandardUsersMacOS11Plus": true 167 | }, 168 | { 169 | "mdmKey": "SpeechRecognition", 170 | "englishName": "Speech Recognition", 171 | "englishDescription": "Allows specified apps to use the system Speech Recognition feature and to send speech data to Apple." 172 | }, 173 | { 174 | "mdmKey": "SystemPolicyAllFiles", 175 | "englishName": "Full Disk Access", 176 | "englishDescription": "Allows specified apps access to data like Mail, Messages, Safari, Home, Time Machine backups, and certain administrative settings for all users on the Mac.", 177 | "entitlements": 178 | [ 179 | "com.apple.security.files.all", 180 | "com.apple.security.files.user-selected.read-only", 181 | "com.apple.security.files.user-selected.read-write" 182 | ] 183 | }, 184 | { 185 | "mdmKey": "SystemPolicySysAdminFiles", 186 | "englishName": "Administrator Files", 187 | "englishDescription": "Allows specified apps access to some files used by system administrators.", 188 | "entitlements": 189 | [ 190 | "com.apple.security.files.user-selected.read-only", 191 | "com.apple.security.files.user-selected.read-write" 192 | ] 193 | } 194 | ] 195 | -------------------------------------------------------------------------------- /Source/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2018 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Cocoa 29 | 30 | @NSApplicationMain 31 | class AppDelegate: NSObject, NSApplicationDelegate { 32 | 33 | func applicationDidFinishLaunching(_ aNotification: Notification) {} 34 | 35 | func applicationWillTerminate(_ aNotification: Notification) {} 36 | 37 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 38 | return true 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /Source/Extensions/ArrayExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArrayExtensions.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2018 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | extension Array where Element: Equatable { 30 | mutating func appendIfNew(_ item: Element) { 31 | if !contains(item) { 32 | append(item) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Source/Extensions/LoggerExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoggerExtensions.swift 3 | // PPPC Utility 4 | // 5 | // Created by Skyler Godfrey on 10/21/24. 6 | // Copyright © 2024 Jamf. All rights reserved. 7 | // 8 | 9 | // This extension simplifies the logger instance creation by calling the bundle Id 10 | // and pre-declaring categories. Currently the predefined categories match the 11 | // class name. 12 | 13 | import OSLog 14 | 15 | extension Logger { 16 | static let subsystem = Bundle.main.bundleIdentifier! 17 | static let TCCProfileViewController = Logger(subsystem: subsystem, category: "TCCProfileViewController") 18 | static let PPPCServicesManager = Logger(subsystem: subsystem, category: "PPPCServicesManager") 19 | static let Model = Logger(subsystem: subsystem, category: "Model") 20 | static let SaveViewController = Logger(subsystem: subsystem, category: "SaveViewController") 21 | static let UploadInfoView = Logger(subsystem: subsystem, category: "UploadInfoView") 22 | static let UploadManager = Logger(subsystem: subsystem, category: "UploadManager") 23 | } 24 | -------------------------------------------------------------------------------- /Source/Extensions/TCCProfileExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TCCProfileExtensions.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2020 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | 30 | public extension TCCProfile { 31 | 32 | enum ParseError: Error { 33 | case failedToCreateDecoder 34 | } 35 | 36 | /// Create a ``TCCProfile`` object from a `Data` containing a provisioning profile. 37 | /// - Parameter profileData: The raw profile data (generally from a file read operation). This may be CMS encoded. 38 | /// - Returns: A ``TCCProfile`` instance. 39 | /// - Throws: Either a ``TCCProfile.ParseError`` or a `DecodingError`. 40 | static func parse(from profileData: Data) throws -> TCCProfile { 41 | // The profile may be CMS encoded; let's try to decode it. 42 | guard let cmsDecoder = SwiftyCMSDecoder() else { 43 | throw ParseError.failedToCreateDecoder 44 | } 45 | cmsDecoder.updateMessage(data: profileData as NSData) 46 | cmsDecoder.finaliseMessage() 47 | 48 | var plistData: Data 49 | if let decodedData = cmsDecoder.data { 50 | // Use the decoded data if CMS decoding worked. 51 | plistData = decodedData 52 | } else { 53 | // Assume it failed because it's not encrypted and move on to deserialize with original data. 54 | plistData = profileData 55 | } 56 | 57 | // Adjust letter case of required keys to be more flexible during import. 58 | plistData = fixLetterCase(of: plistData) 59 | 60 | return try PropertyListDecoder().decode(TCCProfile.self, from: plistData) 61 | } 62 | 63 | /// Adjust the letter case of required profile keys to meet the standard during import. 64 | /// - Parameter original: The original data from the file 65 | /// - Returns: (Possibly) updated data with all of the required profile keys meeting the standard letter case. 66 | private static func fixLetterCase(of original: Data) -> Data { 67 | guard let originalString = String(data: original, encoding: .utf8) else { 68 | return original 69 | } 70 | var newString = originalString 71 | 72 | // This block looks for the given coding key within a property list XML string and 73 | // converts it to the standard letter case. 74 | let conversionBlock = { (codingKey: CodingKey) in 75 | let requiredString = ">\(codingKey.stringValue)<" 76 | newString = newString.replacingOccurrences(of: requiredString, 77 | with: requiredString, 78 | options: .caseInsensitive) 79 | } 80 | 81 | // Currently there are three model structs that are used to decode the profile. 82 | TCCProfile.CodingKeys.allCases.forEach(conversionBlock) 83 | TCCProfile.Content.CodingKeys.allCases.forEach(conversionBlock) 84 | TCCPolicy.CodingKeys.allCases.forEach(conversionBlock) 85 | 86 | // Convert the String back to Data 87 | guard let newData = newString.data(using: .utf8) else { 88 | return original 89 | } 90 | 91 | return newData 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Source/Model/AppleEventRule.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppleEventRule.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2018 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Cocoa 29 | 30 | class AppleEventRule: NSObject { 31 | 32 | @objc dynamic var source: Executable! 33 | @objc dynamic var destination: Executable! 34 | @objc dynamic var valueString: String! = TCCProfileDisplayValue.allow.rawValue 35 | 36 | var value: Bool { return valueString == TCCProfileDisplayValue.allow.rawValue } 37 | 38 | init(source: Executable, destination: Executable, value: Bool) { 39 | self.source = source 40 | self.destination = destination 41 | self.valueString = value ? TCCProfileDisplayValue.allow.rawValue : TCCProfileDisplayValue.deny.rawValue 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Source/Model/Executable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Executable.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2018 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Cocoa 29 | 30 | class Executable: NSObject { 31 | 32 | @objc dynamic var iconPath: String! 33 | 34 | @objc dynamic var displayName: String! 35 | @objc dynamic var identifier: String! 36 | @objc dynamic var codeRequirement: String! 37 | 38 | @objc dynamic var policy: Policy = Policy() 39 | @objc dynamic var appleEvents: [AppleEventRule] = [] 40 | 41 | override init() { 42 | super.init() 43 | } 44 | 45 | init(identifier: String, codeRequirement: String, _ displayName: String? = nil) { 46 | super.init() 47 | 48 | self.identifier = identifier 49 | self.codeRequirement = codeRequirement 50 | if displayName != nil { 51 | self.displayName = displayName 52 | } else { 53 | self.displayName = generateDisplayName(identifier: identifier) 54 | } 55 | self.iconPath = generateIconPath(identifier: identifier) 56 | } 57 | 58 | func generateDisplayName(identifier: String) -> String { 59 | var separatedBy = "." 60 | if identifier.contains("/") { 61 | separatedBy = "/" 62 | } 63 | let partNames = identifier.components(separatedBy: separatedBy) 64 | 65 | return partNames.last ?? identifier 66 | } 67 | 68 | func generateIconPath(identifier: String) -> String { 69 | if identifier.contains("/") { 70 | return IconFilePath.binary 71 | } else { 72 | return IconFilePath.application 73 | } 74 | } 75 | } 76 | 77 | class Policy: NSObject { 78 | // swiftlint:disable identifier_name 79 | @objc dynamic var AddressBook: String = "-" 80 | @objc dynamic var Calendar: String = "-" 81 | @objc dynamic var Reminders: String = "-" 82 | @objc dynamic var Photos: String = "-" 83 | @objc dynamic var Camera: String = "-" 84 | @objc dynamic var Microphone: String = "-" 85 | @objc dynamic var Accessibility: String = "-" 86 | @objc dynamic var PostEvent: String = "-" 87 | @objc dynamic var SystemPolicyAllFiles: String = "-" 88 | @objc dynamic var SystemPolicySysAdminFiles: String = "-" 89 | @objc dynamic var FileProviderPresence: String = "-" 90 | @objc dynamic var ListenEvent: String = "-" 91 | @objc dynamic var MediaLibrary: String = "-" 92 | @objc dynamic var ScreenCapture: String = "-" 93 | @objc dynamic var SpeechRecognition: String = "-" 94 | @objc dynamic var SystemPolicyDesktopFolder: String = "-" 95 | @objc dynamic var SystemPolicyDocumentsFolder: String = "-" 96 | @objc dynamic var SystemPolicyDownloadsFolder: String = "-" 97 | @objc dynamic var SystemPolicyNetworkVolumes: String = "-" 98 | @objc dynamic var SystemPolicyRemovableVolumes: String = "-" 99 | // swiftlint:enable identifier_name 100 | 101 | func allPolicyValues() -> [String] { 102 | let mirror = Mirror(reflecting: self) 103 | return mirror.children.compactMap { _, value in 104 | return value as? String 105 | } 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /Source/Model/LoadExecutableError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadExecutableError.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2019 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | public enum LoadExecutableError: Error { 30 | case identifierNotFound 31 | case resourceURLNotFound 32 | case codeRequirementError(description: String) 33 | case executableNotFound 34 | case executableAlreadyExists 35 | } 36 | 37 | extension LoadExecutableError: LocalizedError { 38 | public var errorDescription: String? { 39 | switch self { 40 | case .identifierNotFound: 41 | return "Bundle identifier could not be found." 42 | case .resourceURLNotFound: 43 | return "Resource URL could not be found." 44 | case .codeRequirementError(let description): 45 | return "Failed to get designated code requirement. The executable may not be signed. Error: \(description)" 46 | case .executableNotFound: 47 | return "Could not find executable from url path" 48 | case .executableAlreadyExists: 49 | return "The executable is already loaded." 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Source/Model/Model.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Model.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2018 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Cocoa 29 | import OSLog 30 | 31 | @objc class Model: NSObject { 32 | 33 | var usingLegacyAllowKey = true 34 | 35 | @objc dynamic var current: Executable? 36 | @objc dynamic static let shared = Model() 37 | @objc dynamic var identities: [SigningIdentity] = [] 38 | @objc dynamic var selectedExecutables: [Executable] = [] 39 | 40 | let logger = Logger.Model 41 | 42 | func getAppleEventChoices(executable: Executable) -> [Executable] { 43 | var executables: [Executable] = [] 44 | 45 | loadExecutable(url: URL(fileURLWithPath: "/System/Library/CoreServices/System Events.app")) { result in 46 | switch result { 47 | case .success(let executable): 48 | executables.append(executable) 49 | case .failure(let error): 50 | self.logger.error("\(error)") 51 | } 52 | } 53 | 54 | loadExecutable(url: URL(fileURLWithPath: "/System/Library/CoreServices/SystemUIServer.app")) { result in 55 | switch result { 56 | case .success(let executable): 57 | executables.append(executable) 58 | case .failure(let error): 59 | self.logger.error("\(error)") 60 | } 61 | } 62 | 63 | loadExecutable(url: URL(fileURLWithPath: "/System/Library/CoreServices/Finder.app")) { result in 64 | switch result { 65 | case .success(let executable): 66 | executables.append(executable) 67 | case .failure(let error): 68 | self.logger.error("\(error)") 69 | } 70 | } 71 | 72 | let others = store.values.filter { $0 != executable && !Set(executables).contains($0) } 73 | executables.append(contentsOf: others) 74 | 75 | return executables 76 | } 77 | 78 | var store: [String: Executable] = [:] 79 | public var importedTCCProfile: TCCProfile? 80 | } 81 | 82 | // MARK: Loading executable 83 | 84 | struct IconFilePath { 85 | static let binary = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/ExecutableBinaryIcon.icns" 86 | static let application = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns" 87 | static let kext = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/KEXT.icns" 88 | static let unknown = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericQuestionMarkIcon.icns" 89 | } 90 | 91 | typealias LoadExecutableResult = Result 92 | typealias LoadExecutableCompletion = ((LoadExecutableResult) -> Void) 93 | 94 | extension Model { 95 | 96 | func requiresAuthorizationKey() -> Bool { 97 | return selectedExecutables.contains { exe -> Bool in 98 | return exe.policy.allPolicyValues().contains { value -> Bool in 99 | return value == TCCProfileDisplayValue.allowStandardUsersToApprove.rawValue 100 | } 101 | } 102 | } 103 | 104 | /// Will convert any Authorization key values to the legacy Allowed key 105 | func changeToUseLegacyAllowKey() { 106 | usingLegacyAllowKey = true 107 | selectedExecutables.forEach { exe in 108 | if exe.policy.ListenEvent == TCCProfileDisplayValue.allowStandardUsersToApprove.rawValue { 109 | exe.policy.ListenEvent = "-" 110 | } 111 | if exe.policy.ScreenCapture == TCCProfileDisplayValue.allowStandardUsersToApprove.rawValue { 112 | exe.policy.ScreenCapture = "-" 113 | } 114 | } 115 | } 116 | 117 | // TODO - refactor this method so it isn't so complex 118 | // swiftlint:disable:next cyclomatic_complexity 119 | func loadExecutable(url: URL, completion: @escaping LoadExecutableCompletion) { 120 | let executable = Executable() 121 | 122 | if let bundle = Bundle(url: url) { 123 | guard let identifier = bundle.bundleIdentifier else { 124 | return completion(.failure(.identifierNotFound)) 125 | } 126 | executable.identifier = identifier 127 | let info = bundle.infoDictionary 128 | executable.displayName = (info?["CFBundleName"] as? String) ?? executable.identifier 129 | if let resourcesURL = bundle.resourceURL { 130 | if let definedIconFile = info?["CFBundleIconFile"] as? String { 131 | var iconURL = resourcesURL.appendingPathComponent(definedIconFile) 132 | if iconURL.pathExtension.isEmpty { 133 | iconURL.appendPathExtension("icns") 134 | } 135 | executable.iconPath = iconURL.path 136 | } else { 137 | executable.iconPath = resourcesURL.appendingPathComponent("DefaultAppIcon.icns").path 138 | } 139 | 140 | if !FileManager.default.fileExists(atPath: executable.iconPath) { 141 | switch url.pathExtension { 142 | case "app": 143 | executable.iconPath = IconFilePath.application 144 | case "bundle": 145 | executable.iconPath = IconFilePath.kext 146 | case "xpc": 147 | executable.iconPath = IconFilePath.kext 148 | default: 149 | executable.iconPath = IconFilePath.unknown 150 | } 151 | } 152 | } else { 153 | return completion(.failure(.resourceURLNotFound)) 154 | } 155 | } else { 156 | executable.identifier = url.path 157 | executable.displayName = url.lastPathComponent 158 | executable.iconPath = IconFilePath.binary 159 | } 160 | 161 | if let alreadyFoundExecutable = store[executable.identifier] { 162 | return completion(.success(alreadyFoundExecutable)) 163 | } 164 | 165 | do { 166 | executable.codeRequirement = try SecurityWrapper.copyDesignatedRequirement(url: url) 167 | store[executable.identifier] = executable 168 | return completion(.success(executable)) 169 | } catch { 170 | return completion(.failure(.codeRequirementError(description: error.localizedDescription))) 171 | } 172 | } 173 | } 174 | 175 | // MARK: Exporting Profile 176 | 177 | extension Model { 178 | 179 | func exportProfile(organization: String, identifier: String, displayName: String, payloadDescription: String) -> TCCProfile { 180 | var services = [String: [TCCPolicy]]() 181 | 182 | selectedExecutables.forEach { executable in 183 | 184 | let mirroredServices = Mirror(reflecting: executable.policy) 185 | 186 | for attr in mirroredServices.children { 187 | if let key = attr.label, let value = attr.value as? String { 188 | if let policyToAppend = policyFromString(executable: executable, value: value) { 189 | services[key] = services[key] ?? [] 190 | services[key]?.append(policyToAppend) 191 | } 192 | } 193 | } 194 | 195 | executable.appleEvents.forEach { event in 196 | let policy = policyFromString(executable: executable, value: event.valueString, event: event) 197 | if let policy = policy { 198 | let appleEventsKey = ServicesKeys.appleEvents.rawValue 199 | services[appleEventsKey] = services[appleEventsKey] ?? [] 200 | services[appleEventsKey]?.append(policy) 201 | } 202 | } 203 | } 204 | 205 | return TCCProfile(organization: organization, 206 | identifier: identifier, 207 | displayName: displayName, 208 | payloadDescription: payloadDescription, 209 | services: services) 210 | } 211 | 212 | func importProfile(tccProfile: TCCProfile) { 213 | if let content = tccProfile.content.first { 214 | self.cleanUpAndRemoveDependencies() 215 | 216 | self.importedTCCProfile = tccProfile 217 | 218 | for (key, policies) in content.services { 219 | getExecutablesFromAllPolicies(policies: policies) 220 | 221 | for policy in policies { 222 | let executable = getExecutableFromSelectedExecutables(bundleIdentifier: policy.identifier) 223 | if key == ServicesKeys.appleEvents.rawValue { 224 | if let source = executable, 225 | let rIdentifier = policy.receiverIdentifier, 226 | let rCodeRequirement = policy.receiverCodeRequirement { 227 | let destination = getExecutableFrom(identifier: rIdentifier, codeRequirement: rCodeRequirement) 228 | let allowed: Bool = (policy.allowed == true || policy.authorization == TCCPolicyAuthorizationValue.allow) 229 | let appleEvent = AppleEventRule(source: source, destination: destination, value: allowed) 230 | executable?.appleEvents.appendIfNew(appleEvent) 231 | } 232 | } else { 233 | if policy.authorization == .allow || policy.allowed == true { 234 | executable?.policy.setValue(TCCProfileDisplayValue.allow.rawValue, forKey: key) 235 | } else if policy.authorization == .allowStandardUserToSetSystemService { 236 | executable?.policy.setValue(TCCProfileDisplayValue.allowStandardUsersToApprove.rawValue, forKey: key) 237 | } else { 238 | executable?.policy.setValue(TCCProfileDisplayValue.deny.rawValue, forKey: key) 239 | } 240 | } 241 | } 242 | } 243 | } 244 | } 245 | 246 | func policyFromString(executable: Executable, value: String, event: AppleEventRule? = nil) -> TCCPolicy? { 247 | var policy = TCCPolicy(identifier: executable.identifier, 248 | codeRequirement: executable.codeRequirement, 249 | receiverIdentifier: event?.destination.identifier, 250 | receiverCodeRequirement: event?.destination.codeRequirement) 251 | if usingLegacyAllowKey { 252 | switch value { 253 | case TCCProfileDisplayValue.allow.rawValue: 254 | policy.allowed = true 255 | case TCCProfileDisplayValue.deny.rawValue: 256 | policy.allowed = false 257 | default: 258 | return nil 259 | } 260 | } else { 261 | switch value { 262 | case TCCProfileDisplayValue.allow.rawValue: 263 | policy.authorization = .allow 264 | case TCCProfileDisplayValue.deny.rawValue: 265 | policy.authorization = .deny 266 | case TCCProfileDisplayValue.allowStandardUsersToApprove.rawValue: 267 | policy.authorization = .allowStandardUserToSetSystemService 268 | default: 269 | return nil 270 | } 271 | } 272 | return policy 273 | } 274 | 275 | func getExecutablesFromAllPolicies(policies: [TCCPolicy]) { 276 | for tccPolicy in policies where getExecutableFromSelectedExecutables(bundleIdentifier: tccPolicy.identifier) == nil { 277 | let executable = getExecutableFrom(identifier: tccPolicy.identifier, codeRequirement: tccPolicy.codeRequirement) 278 | self.selectedExecutables.append(executable) 279 | } 280 | } 281 | 282 | func getExecutableFromSelectedExecutables(bundleIdentifier: String) -> Executable? { 283 | for executable in selectedExecutables where executable.identifier == bundleIdentifier { 284 | return executable 285 | } 286 | return nil 287 | } 288 | 289 | func getExecutableFrom(identifier: String, codeRequirement: String) -> Executable { 290 | var executable = Executable(identifier: identifier, codeRequirement: codeRequirement) 291 | findExecutableOnComputerUsing(bundleIdentifier: identifier) { result in 292 | switch result { 293 | case .success(let goodExecutable): 294 | executable = goodExecutable 295 | case .failure(let error): 296 | self.logger.error("\(error)") 297 | } 298 | } 299 | 300 | return executable 301 | } 302 | 303 | private func findExecutableOnComputerUsing(bundleIdentifier: String, completion: @escaping LoadExecutableCompletion) { 304 | var urlToLoad: URL? 305 | if bundleIdentifier.contains("/") { 306 | urlToLoad = URL(string: "file://\(bundleIdentifier)") 307 | } else { 308 | urlToLoad = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) 309 | } 310 | 311 | if let fileURL = urlToLoad { 312 | self.loadExecutable(url: fileURL) { result in 313 | switch result { 314 | case .success(let executable): 315 | return completion(.success(executable)) 316 | case .failure(let error): 317 | return completion(.failure(error)) 318 | } 319 | } 320 | } 321 | return completion(.failure(.executableNotFound)) 322 | } 323 | 324 | private func cleanUpAndRemoveDependencies() { 325 | for executable in self.selectedExecutables { 326 | executable.appleEvents = [] 327 | executable.policy = Policy() 328 | } 329 | self.selectedExecutables = [] 330 | self.current = nil 331 | self.store.removeAll() 332 | self.importedTCCProfile = nil 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /Source/Model/PPPCServiceInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PPPCServiceInfo.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2022 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | 30 | /// Holds the information about a single PPPC Service provided by Apple. 31 | struct PPPCServiceInfo: Decodable { 32 | let mdmKey: String 33 | let englishName: String 34 | let englishDescription: String 35 | let entitlements: [String]? 36 | let denyOnly: Bool? 37 | let allowStandardUsersMacOS11Plus: Bool? 38 | 39 | var userHelp: String { 40 | if let entitlements = entitlements { 41 | return "\(englishDescription)\n\nMDM Key: \(mdmKey)\nRelated entitlements: \(entitlements)" 42 | } else { 43 | return "\(englishDescription)\n\nMDM Key: \(mdmKey)" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Source/Model/PPPCServicesManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PPPCServicesManager.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2022 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | import OSLog 30 | 31 | class PPPCServicesManager { 32 | 33 | typealias MDMServiceKey = String 34 | 35 | let logger = Logger.PPPCServicesManager 36 | 37 | static let shared = PPPCServicesManager() 38 | 39 | let allServices: [MDMServiceKey: PPPCServiceInfo] 40 | 41 | init() { 42 | var hashed = [MDMServiceKey: PPPCServiceInfo]() 43 | 44 | do { 45 | guard let dataURL = Bundle.main.url(forResource: "PPPCServices", withExtension: "json") else { 46 | throw CocoaError(.fileNoSuchFile) 47 | } 48 | 49 | let data = try Data(contentsOf: dataURL) 50 | let decoder = JSONDecoder() 51 | let loadedServices = try decoder.decode([PPPCServiceInfo].self, from: data) 52 | 53 | loadedServices.forEach { service in 54 | hashed[service.mdmKey] = service 55 | } 56 | } catch { 57 | logger.error("Error loading PPPCServices.json: \(error)") 58 | } 59 | 60 | allServices = hashed 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Source/Model/SemanticVersion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SemanticVersion.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2022 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | /// A simple struct defining a semantic version number. 29 | struct SemanticVersion: Comparable { 30 | let major: Int 31 | let minor: Int 32 | let patch: Int 33 | 34 | static func < (left: SemanticVersion, right: SemanticVersion) -> Bool { 35 | if left.major < right.major { 36 | return true 37 | } 38 | 39 | if left.major == right.major { 40 | if left.minor < right.minor { 41 | return true 42 | } 43 | 44 | if left.minor == right.minor && left.patch < right.patch { 45 | return true 46 | } 47 | } 48 | 49 | return false 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Source/Model/SigningIdentity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SigningIdentity.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2018 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Cocoa 29 | 30 | class SigningIdentity: NSObject { 31 | 32 | @objc dynamic var displayName: String 33 | var reference: SecIdentity? 34 | 35 | init(name: String, reference: SecIdentity?) { 36 | displayName = name 37 | super.init() 38 | self.reference = reference 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Source/Model/TCCProfile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TCCProfile.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2018 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | 30 | typealias TCCPolicyIdentifierType = String 31 | typealias TCCPolicyAuthorizationValue = String 32 | 33 | extension TCCPolicyIdentifierType { 34 | static let bundleID = "bundleID" 35 | static let path = "path" 36 | } 37 | 38 | extension TCCPolicyAuthorizationValue { 39 | static let allow = "Allow" 40 | static let deny = "Deny" 41 | static let allowStandardUserToSetSystemService = "AllowStandardUserToSetSystemService" 42 | } 43 | 44 | struct TCCPolicy: Codable { 45 | var comment: String 46 | var identifier: String 47 | var identifierType: TCCPolicyIdentifierType 48 | var codeRequirement: String 49 | 50 | /// legacy value to allow or deny a service. When setting this value, the authorization will be set to nil 51 | /// as the values are mutually exclusive. If authorization is present it will always be used, so we have no 52 | /// need to nil out this value if authorization is set. 53 | var allowed: Bool? { 54 | didSet { 55 | authorization = nil 56 | } 57 | } 58 | var authorization: TCCPolicyAuthorizationValue? 59 | var receiverIdentifier: String? 60 | var receiverIdentifierType: TCCPolicyIdentifierType? 61 | var receiverCodeRequirement: String? 62 | 63 | enum CodingKeys: String, CodingKey, CaseIterable { 64 | case identifier = "Identifier" 65 | case identifierType = "IdentifierType" 66 | case allowed = "Allowed" 67 | case authorization = "Authorization" 68 | case codeRequirement = "CodeRequirement" 69 | case comment = "Comment" 70 | case receiverIdentifier = "AEReceiverIdentifier" 71 | case receiverIdentifierType = "AEReceiverIdentifierType" 72 | case receiverCodeRequirement = "AEReceiverCodeRequirement" 73 | } 74 | 75 | init(identifier: String, codeRequirement: String, receiverIdentifier: String? = nil, receiverCodeRequirement: String? = nil) { 76 | self.comment = "" 77 | self.identifier = identifier 78 | self.identifierType = identifier.contains("/") ? .path : .bundleID 79 | self.codeRequirement = codeRequirement 80 | self.receiverIdentifier = receiverIdentifier 81 | if let otherIdentifier = receiverIdentifier { 82 | self.receiverIdentifierType = otherIdentifier.contains("/") ? .path : .bundleID 83 | } 84 | self.receiverCodeRequirement = receiverCodeRequirement 85 | } 86 | } 87 | 88 | public struct TCCProfile: Codable { 89 | struct Content: Codable { 90 | var payloadDescription: String 91 | var displayName: String 92 | var identifier: String 93 | var organization: String 94 | var type: String 95 | var uuid: String 96 | var version: Int 97 | var services: [String: [TCCPolicy]] 98 | 99 | // swiftlint:disable:next nesting 100 | enum CodingKeys: String, CodingKey, CaseIterable { 101 | case payloadDescription = "PayloadDescription" 102 | case displayName = "PayloadDisplayName" 103 | case identifier = "PayloadIdentifier" 104 | case organization = "PayloadOrganization" 105 | case type = "PayloadType" 106 | case uuid = "PayloadUUID" 107 | case version = "PayloadVersion" 108 | case services = "Services" 109 | } 110 | } 111 | 112 | var version: Int 113 | var uuid: String 114 | var type: String 115 | var scope: String 116 | var organization: String 117 | var identifier: String 118 | var displayName: String 119 | var payloadDescription: String 120 | var content: [Content] 121 | enum CodingKeys: String, CodingKey, CaseIterable { 122 | case payloadDescription = "PayloadDescription" 123 | case displayName = "PayloadDisplayName" 124 | case identifier = "PayloadIdentifier" 125 | case organization = "PayloadOrganization" 126 | case scope = "PayloadScope" 127 | case type = "PayloadType" 128 | case uuid = "PayloadUUID" 129 | case version = "PayloadVersion" 130 | case content = "PayloadContent" 131 | } 132 | init(organization: String, identifier: String, displayName: String, payloadDescription: String, services: [String: [TCCPolicy]]) { 133 | let content = Content(payloadDescription: payloadDescription, 134 | displayName: displayName, 135 | identifier: identifier, 136 | organization: organization, 137 | type: "com.apple.TCC.configuration-profile-policy", 138 | uuid: UUID().uuidString, 139 | version: 1, 140 | services: services) 141 | self.version = 1 142 | self.uuid = UUID().uuidString 143 | self.type = "Configuration" 144 | self.scope = "System" 145 | self.organization = content.organization 146 | self.identifier = content.identifier 147 | self.displayName = content.displayName 148 | self.payloadDescription = content.payloadDescription 149 | self.content = [content] 150 | } 151 | 152 | func xmlData() throws -> Data { 153 | let encoder = PropertyListEncoder() 154 | encoder.outputFormat = .xml 155 | return try encoder.encode(self) 156 | } 157 | 158 | /// Wraps the ``TCCProfile`` in the XML tags for upload to the Jamf Pro API. 159 | /// - Parameters: 160 | /// - signingIdentity: A signing identity; can be nil to leave the profile unsigned. 161 | /// - site: A Jamf Pro site 162 | /// - Returns: XML data for use with the Jamf Pro API. 163 | func jamfProAPIData(signingIdentity: SecIdentity?, site: (String, String)?) throws -> Data { 164 | var profileText: String 165 | var profileData = try xmlData() 166 | if let identity = signingIdentity { 167 | profileData = try SecurityWrapper.sign(data: profileData, using: identity) 168 | } 169 | profileText = String(data: profileData, encoding: .utf8) ?? "" 170 | 171 | let root = XMLElement(name: "os_x_configuration_profile") 172 | let general = XMLElement(name: "general") 173 | root.addChild(general) 174 | 175 | let payloads = XMLElement(name: "payloads", stringValue: profileText) 176 | 177 | general.addChild(payloads) 178 | 179 | if let site = site { 180 | let sites = XMLElement(name: "site") 181 | let siteId = XMLElement(name: "id", stringValue: site.0) 182 | let siteName = XMLElement(name: "name", stringValue: site.1) 183 | sites.addChild(siteId) 184 | sites.addChild(siteName) 185 | general.addChild(sites) 186 | } 187 | 188 | general.addChild(XMLElement(name: "name", stringValue: displayName)) 189 | general.addChild(XMLElement(name: "description", stringValue: payloadDescription)) 190 | 191 | let xml = XMLDocument(rootElement: root) 192 | return xml.xmlData 193 | } 194 | } 195 | 196 | enum ServicesKeys: String { 197 | case addressBook = "AddressBook" 198 | case calendar = "Calendar" 199 | case reminders = "Reminders" 200 | case photos = "Photos" 201 | case camera = "Camera" 202 | case microphone = "Microphone" 203 | case accessibility = "Accessibility" 204 | case postEvent = "PostEvent" 205 | case allFiles = "SystemPolicyAllFiles" 206 | case adminFiles = "SystemPolicySysAdminFiles" 207 | case fileProviderPresence = "FileProviderPresence" 208 | case listenEvent = "ListenEvent" 209 | case mediaLibrary = "MediaLibrary" 210 | case screenCapture = "ScreenCapture" 211 | case speechRecognition = "SpeechRecognition" 212 | case desktopFolder = "SystemPolicyDesktopFolder" 213 | case documentsFolder = "SystemPolicyDocumentsFolder" 214 | case downloadsFolder = "SystemPolicyDownloadsFolder" 215 | case networkVolumes = "SystemPolicyNetworkVolumes" 216 | case removableVolumes = "SystemPolicyRemovableVolumes" 217 | case appleEvents = "AppleEvents" 218 | } 219 | -------------------------------------------------------------------------------- /Source/Networking/JamfProAPIClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JamfProAPIClient.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | 30 | class JamfProAPIClient: Networking { 31 | let applicationJson = "application/json" 32 | 33 | override func getBearerToken(authInfo: AuthenticationInfo) async throws -> Token { 34 | switch authInfo { 35 | case .basicAuth: 36 | let endpoint = "api/v1/auth/token" 37 | var request = try url(forEndpoint: endpoint) 38 | 39 | request.httpMethod = "POST" 40 | request.setValue(applicationJson, forHTTPHeaderField: "Accept") 41 | 42 | return try await loadBasicAuthorized(request: request) 43 | case .clientCreds(let id, let secret): 44 | let request = try oauthTokenRequest(clientId: id, clientSecret: secret) 45 | 46 | return try await loadPreAuthorized(request: request) 47 | } 48 | } 49 | 50 | /// Creates the OAuth client credentials token request 51 | /// - Parameters: 52 | /// - clientId: The client ID 53 | /// - clientSecret: The client secret 54 | /// - Returns: A `URLRequest` that is ready to send to acquire an OAuth token. 55 | func oauthTokenRequest(clientId: String, clientSecret: String) throws -> URLRequest { 56 | let endpoint = "api/oauth/token" 57 | var request = try url(forEndpoint: endpoint) 58 | 59 | request.httpMethod = "POST" 60 | request.setValue(applicationJson, forHTTPHeaderField: "Accept") 61 | 62 | var components = URLComponents() 63 | components.queryItems = [URLQueryItem(name: "grant_type", value: "client_credentials"), 64 | URLQueryItem(name: "client_id", value: clientId), 65 | URLQueryItem(name: "client_secret", value: clientSecret)] 66 | 67 | request.httpBody = components.percentEncodedQuery?.data(using: .utf8) 68 | 69 | return request 70 | } 71 | 72 | // MARK: - Requests with fallback auth 73 | 74 | /// Make a network request and decode the response using bearer auth if possible, falling back to basic auth if needed. 75 | /// - Parameter request: The `URLRequest` to make 76 | /// - Returns: The decoded response. 77 | func load(request: URLRequest) async throws -> T { 78 | let result: T 79 | 80 | if await authManager.bearerAuthSupported() { 81 | do { 82 | result = try await loadBearerAuthorized(request: request) 83 | } catch AuthError.bearerAuthNotSupported { 84 | // Note that `authManager` will automatically return false from future calls to `bearerAuthSupported()` 85 | // Possibly we're talking to Jamf Pro 10.34.x or lower and we can retry with Basic Auth 86 | result = try await loadBasicAuthorized(request: request) 87 | } 88 | } else { 89 | result = try await loadBasicAuthorized(request: request) 90 | } 91 | 92 | return result 93 | } 94 | 95 | /// Send a network request and return the response using bearer auth if possible, falling back to basic auth if needed. 96 | /// - Parameter request: The `URLRequest` to make 97 | /// - Returns: The raw data of a successful network response. 98 | func send(request: URLRequest) async throws -> Data { 99 | let result: Data 100 | 101 | if await authManager.bearerAuthSupported() { 102 | do { 103 | result = try await sendBearerAuthorized(request: request) 104 | } catch AuthError.bearerAuthNotSupported { 105 | // Note that authManager will automatically return false from future calls to ``bearerAuthSupported()`` 106 | // Possibly we're talking to Jamf Pro 10.34.x or lower and we can retry with Basic Auth 107 | result = try await sendBasicAuthorized(request: request) 108 | } 109 | } else { 110 | result = try await sendBasicAuthorized(request: request) 111 | } 112 | 113 | return result 114 | } 115 | 116 | // MARK: - Useful API endpoints 117 | 118 | /// Reads the Jamf Pro organization name 119 | /// 120 | /// Requires "Read Activation Code" permission in the API 121 | /// - Parameter profileData: The prepared profile data 122 | func getOrganizationName() async throws -> String { 123 | let endpoint = "JSSResource/activationcode" 124 | var request = try url(forEndpoint: endpoint) 125 | 126 | request.httpMethod = "GET" 127 | request.setValue(applicationJson, forHTTPHeaderField: "Accept") 128 | 129 | let info: ActivationCode 130 | info = try await load(request: request) 131 | 132 | return info.activationCode.organizationName 133 | } 134 | 135 | /// Gets the Jamf Pro version 136 | /// 137 | /// No specific permissions required. 138 | /// - Returns: The Jamf Pro server version 139 | func getJamfProVersion() async throws -> JamfProVersion { 140 | let endpoint = "api/v1/jamf-pro-version" 141 | var request = try url(forEndpoint: endpoint) 142 | 143 | request.httpMethod = "GET" 144 | request.setValue(applicationJson, forHTTPHeaderField: "Accept") 145 | 146 | let info: JamfProVersion 147 | do { 148 | info = try await load(request: request) 149 | } catch is NetworkingError { 150 | // Possibly we are talking to Jamf Pro v10.22 or lower and we can grab the version from a meta tag on the login page. 151 | let simpleRequest = try url(forEndpoint: "") 152 | let (data, _) = try await URLSession.shared.data(for: simpleRequest) 153 | info = try JamfProVersion(fromHTMLString: String(data: data, encoding: .utf8)) 154 | } 155 | 156 | return info 157 | } 158 | 159 | /// Uploads a computer configuration profile 160 | /// 161 | /// Requires "Create macOS Configuration Profiles" permission in the API 162 | /// - Parameter profileData: The prepared profile data 163 | func upload(computerConfigProfile profileData: Data) async throws { 164 | let endpoint = "JSSResource/osxconfigurationprofiles" 165 | var request = try url(forEndpoint: endpoint) 166 | 167 | request.httpMethod = "POST" 168 | request.httpBody = profileData 169 | request.setValue("text/xml", forHTTPHeaderField: "Content-Type") 170 | request.setValue("text/xml", forHTTPHeaderField: "Accept") 171 | 172 | _ = try await send(request: request) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Source/Networking/JamfProAPITypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JamfProAPITypes.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2022 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | 30 | struct JamfProVersion: Decodable { 31 | let version: String 32 | 33 | func mainVersionInfo() -> String { 34 | return String(version.prefix { character in 35 | return (character.isNumber || character == ".") 36 | }) 37 | } 38 | 39 | func semantic() -> SemanticVersion { 40 | let components = mainVersionInfo().split(separator: ".") 41 | 42 | return SemanticVersion(major: Int(components[0]) ?? 0, minor: Int(components[1]) ?? 0, patch: Int(components[2]) ?? 0) 43 | } 44 | 45 | /// Versions of Jamf Pro less than 10.23 included the version number in a meta tag on the login page. 46 | /// - Parameter text: The HTML contents of the login page. 47 | init(fromHTMLString text: String?) throws { 48 | // we take version from HTML response body 49 | if let text = text, 50 | let startRange = text.range(of: "? 55 | 56 | private var supportsBearerAuth = true 57 | 58 | init(username: String, password: String) { 59 | authInfo = .basicAuth(username: username, password: password) 60 | } 61 | 62 | init(clientId: String, clientSecret: String) { 63 | authInfo = .clientCreds(id: clientId, secret: clientSecret) 64 | } 65 | 66 | func validToken(networking: Networking) async throws -> Token { 67 | if let task = refreshTask { 68 | // A refresh is already running; we'll use those results when ready. 69 | return try await task.value 70 | } 71 | 72 | if let token = currentToken, 73 | token.isValid { 74 | return token 75 | } 76 | 77 | return try await refreshToken(networking: networking) 78 | } 79 | 80 | func refreshToken(networking: Networking) async throws -> Token { 81 | if let task = refreshTask { 82 | // A refresh is already running; we'll use those results when ready. 83 | return try await task.value 84 | } 85 | 86 | // Initiate a refresh. 87 | let task = Task { () throws -> Token in 88 | defer { refreshTask = nil } 89 | 90 | do { 91 | let newToken = try await networking.getBearerToken(authInfo: authInfo) 92 | currentToken = newToken 93 | return newToken 94 | } catch NetworkingError.serverResponse(let responseCode, _) where responseCode == 404 { 95 | // If we got a 404 while trying to get a bearer token the server doesn't support bearer tokens. 96 | supportsBearerAuth = false 97 | throw AuthError.bearerAuthNotSupported 98 | } catch NetworkingError.serverResponse(let responseCode, _) where responseCode == 401 { 99 | // If we got a 401 while trying to get a bearer token the username/password was bad. 100 | throw AuthError.invalidUsernamePassword 101 | } 102 | } 103 | 104 | refreshTask = task 105 | 106 | return try await task.value 107 | } 108 | 109 | /// If bearer authentication is not actually supported, after the first network call trying to use bearer auth this will return false. 110 | /// 111 | /// The default is that bearer authentication is supported. After the first network call attempting to use bearer auth, if the 112 | /// server does not actually support it this will return false. 113 | /// - Returns: True if bearer auth is supported. 114 | func bearerAuthSupported() async -> Bool { 115 | return supportsBearerAuth 116 | } 117 | 118 | /// Properly encodes the username and password for use in Basic authentication. 119 | /// 120 | /// This doesn't mutate any state and only accesses `let` constants so it doesn't need to be actor isolated. 121 | /// - Returns: The encoded data string for use with Basic Auth. 122 | nonisolated func basicAuthString() throws -> String { 123 | guard case .basicAuth(let username, let password) = authInfo, 124 | !username.isEmpty && !password.isEmpty else { 125 | throw AuthError.invalidUsernamePassword 126 | } 127 | return Data("\(username):\(password)".utf8).base64EncodedString() 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Source/Networking/Networking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Networking.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | 30 | /// Problems at the networking error throw this type of error. 31 | enum NetworkingError: Error, Equatable { 32 | /// The server URL cannot be converted into a standard URL. 33 | /// 34 | /// The associated value is the given server URL. 35 | case badServerUrl(String) 36 | 37 | /// If the server returns anything outside of 200...299 this is the error thrown. 38 | /// 39 | /// The first associated value is the HTTP response code. The second associated value is the full URL that was attempted. 40 | case serverResponse(Int, String) 41 | 42 | /// This is thrown if a subclass does not implement ``getBearerToken(authInfo:)`` and then attempts to use bearer tokens. 43 | case unimplemented 44 | } 45 | 46 | class Networking { 47 | let authManager: NetworkAuthManager 48 | let serverUrlString: String 49 | 50 | init(serverUrlString: String, tokenManager: NetworkAuthManager) { 51 | self.serverUrlString = serverUrlString 52 | self.authManager = tokenManager 53 | } 54 | 55 | /// Subclasses must override this to do a network call to return a bearer token. 56 | /// - Returns: A token 57 | func getBearerToken(authInfo: AuthenticationInfo) async throws -> Token { 58 | throw NetworkingError.unimplemented 59 | } 60 | 61 | /// Create a `URLRequest` for the endpoint based on the `serverUrlString` 62 | /// - Parameter endpoint: The endpoint URL path 63 | /// - Returns: A `URLRequest` representing the endpoint on the server 64 | /// - Throws: May throw a `NetworkingError.badServerUrl` error 65 | func url(forEndpoint endpoint: String) throws -> URLRequest { 66 | guard let serverURL = URL(string: serverUrlString) else { 67 | throw NetworkingError.badServerUrl(serverUrlString) 68 | } 69 | let url = serverURL.appendingPathComponent(endpoint) 70 | 71 | return URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 45.0) 72 | } 73 | 74 | /// Sends a `URLRequest` and decodes a response to an endpoint. 75 | /// - Parameter request: A request that already has authorization info. 76 | /// - Returns: The result. 77 | func loadPreAuthorized(request: URLRequest) async throws -> T { 78 | let (data, urlResponse) = try await URLSession.shared.data(for: request) 79 | 80 | if let httpResponse = urlResponse as? HTTPURLResponse { 81 | if httpResponse.statusCode == 401 { 82 | throw AuthError.invalidUsernamePassword 83 | } else if !(200...299).contains(httpResponse.statusCode) { 84 | throw NetworkingError.serverResponse(httpResponse.statusCode, request.url?.absoluteString ?? "") 85 | } 86 | } 87 | 88 | let decoder = JSONDecoder() 89 | let response = try decoder.decode(T.self, from: data) 90 | 91 | return response 92 | } 93 | 94 | /// Sends a `URLRequest` and decodes a response to a Basic Auth protected endpoint. 95 | /// - Parameter request: A request that does not yet include authorization info. 96 | /// - Returns: The result. 97 | func loadBasicAuthorized(request: URLRequest) async throws -> T { 98 | let authorizedRequest = try await authorizeBasic(request: request) 99 | let (data, urlResponse) = try await URLSession.shared.data(for: authorizedRequest) 100 | 101 | if let httpResponse = urlResponse as? HTTPURLResponse { 102 | if httpResponse.statusCode == 401 { 103 | throw AuthError.invalidUsernamePassword 104 | } else if !(200...299).contains(httpResponse.statusCode) { 105 | throw NetworkingError.serverResponse(httpResponse.statusCode, request.url?.absoluteString ?? "") 106 | } 107 | } 108 | 109 | let decoder = JSONDecoder() 110 | let response = try decoder.decode(T.self, from: data) 111 | 112 | return response 113 | } 114 | 115 | /// Sends a `URLRequest` and decodes a response to a Bearer Auth protected endpoint. 116 | /// - Parameter request: A request that does not yet include authorization info. 117 | /// - Returns: The result. 118 | func loadBearerAuthorized(request: URLRequest, allowRetryForAuth: Bool = true) async throws -> T { 119 | let authorizedRequest = try await authorizeBearer(request: request) 120 | let (data, urlResponse) = try await URLSession.shared.data(for: authorizedRequest) 121 | 122 | if let httpResponse = urlResponse as? HTTPURLResponse { 123 | if httpResponse.statusCode == 401 { 124 | if allowRetryForAuth { 125 | _ = try await authManager.refreshToken(networking: self) 126 | return try await loadBearerAuthorized(request: request, allowRetryForAuth: false) 127 | } 128 | 129 | throw AuthError.invalidToken 130 | } else if !(200...299).contains(httpResponse.statusCode) { 131 | throw NetworkingError.serverResponse(httpResponse.statusCode, request.url?.absoluteString ?? "") 132 | } 133 | } 134 | 135 | let decoder = JSONDecoder() 136 | let response = try decoder.decode(T.self, from: data) 137 | 138 | return response 139 | } 140 | 141 | /// Sends a `URLRequest` to a Bearer Auth protected endpoint and returns any response. 142 | /// - Parameter request: A request that does not yet include authorization info. 143 | /// - Returns: The result. 144 | func sendBearerAuthorized(request: URLRequest, allowRetryForAuth: Bool = true) async throws -> Data { 145 | let authorizedRequest = try await authorizeBearer(request: request) 146 | let (data, urlResponse) = try await URLSession.shared.data(for: authorizedRequest) 147 | 148 | if let httpResponse = urlResponse as? HTTPURLResponse { 149 | if httpResponse.statusCode == 401 { 150 | if allowRetryForAuth { 151 | _ = try await authManager.refreshToken(networking: self) 152 | return try await loadBearerAuthorized(request: request, allowRetryForAuth: false) 153 | } 154 | 155 | throw AuthError.invalidToken 156 | } else if !(200...299).contains(httpResponse.statusCode) { 157 | throw NetworkingError.serverResponse(httpResponse.statusCode, request.url?.absoluteString ?? "") 158 | } 159 | } 160 | 161 | return data 162 | } 163 | 164 | /// Sends a `URLRequest` to a Basic Auth protected endpoint and returns any response. 165 | /// - Parameter request: A request that does not yet include authorization info. 166 | /// - Returns: The result. 167 | func sendBasicAuthorized(request: URLRequest) async throws -> Data { 168 | let authorizedRequest = try await authorizeBasic(request: request) 169 | let (data, urlResponse) = try await URLSession.shared.data(for: authorizedRequest) 170 | 171 | if let httpResponse = urlResponse as? HTTPURLResponse { 172 | if httpResponse.statusCode == 401 { 173 | throw AuthError.invalidUsernamePassword 174 | } else if !(200...299).contains(httpResponse.statusCode) { 175 | throw NetworkingError.serverResponse(httpResponse.statusCode, request.url?.absoluteString ?? "") 176 | } 177 | } 178 | 179 | return data 180 | } 181 | 182 | /// Given a `URLRequest`, adds a bearer token from the ``authManager`` 183 | /// - Parameter request: A URLRequest 184 | /// - Returns: The request with the bearer token added 185 | private func authorizeBearer(request: URLRequest) async throws -> URLRequest { 186 | let token = try await authManager.validToken(networking: self) 187 | var newRequest = request 188 | newRequest.setValue("Bearer \(token.value)", forHTTPHeaderField: "Authorization") 189 | return newRequest 190 | } 191 | 192 | /// Given a `URLRequest`, adds a basic authorization header with info from the ``authManager`` 193 | /// - Parameter request: A URLRequest 194 | /// - Returns: The request with the bearer token added 195 | private func authorizeBasic(request: URLRequest) async throws -> URLRequest { 196 | let basicValue = try authManager.basicAuthString() 197 | var newRequest = request 198 | newRequest.setValue("Basic \(basicValue)", forHTTPHeaderField: "Authorization") 199 | return newRequest 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /Source/Networking/Token.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Token.swift 3 | // PPPC Utility 4 | // 5 | // SPDX-License-Identifier: MIT 6 | // Copyright (c) 2023 Jamf Software 7 | 8 | import Foundation 9 | 10 | /// Network authentication token for Jamf Pro connection. 11 | /// 12 | /// Decodes network response for authentication tokens from Jamf Pro for both the newer OAuth client credentials flow 13 | /// and the older basic-auth-based flow. 14 | struct Token: Decodable { 15 | let value: String 16 | let expiresAt: Date? 17 | 18 | var isValid: Bool { 19 | if let expiration = expiresAt { 20 | return expiration > Date() 21 | } 22 | 23 | return true 24 | } 25 | 26 | enum OAuthTokenCodingKeys: String, CodingKey { 27 | case value = "access_token" 28 | case expire = "expires_in" 29 | } 30 | 31 | enum BasicAuthCodingKeys: String, CodingKey { 32 | case value = "token" 33 | case expireTime = "expires" 34 | } 35 | 36 | init(from decoder: Decoder) throws { 37 | // First try to decode with oauth client credentials token response 38 | let container = try decoder.container(keyedBy: OAuthTokenCodingKeys.self) 39 | let possibleValue = try? container.decode(String.self, forKey: .value) 40 | if let value = possibleValue { 41 | self.value = value 42 | let expireIn = try container.decode(Double.self, forKey: .expire) 43 | self.expiresAt = Date().addingTimeInterval(expireIn) 44 | return 45 | } 46 | 47 | // If that fails try to decode with basic auth token response 48 | let container1 = try decoder.container(keyedBy: BasicAuthCodingKeys.self) 49 | self.value = try container1.decode(String.self, forKey: .value) 50 | let expireTime = try container1.decode(String.self, forKey: .expireTime) 51 | 52 | let formatter = ISO8601DateFormatter() 53 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 54 | self.expiresAt = formatter.date(from: expireTime) 55 | } 56 | 57 | init(value: String, expiresAt: Date) { 58 | self.value = value 59 | self.expiresAt = expiresAt 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Source/Networking/URLSessionAsyncCompatibility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSessionAsyncCompatibility.swift 3 | // Based on AsyncCompatibilityKit's URLSession+Async.swift which is 4 | // Copyright (c) John Sundell 2021 5 | // MIT license, see LICENSE.md file for details 6 | // 7 | // Change from AsyncCompatibilityKit: Modified the `@available` line to be deprecated in macOS 12 instead of iOS 15. 8 | // 9 | 10 | import Foundation 11 | 12 | @available(macOS, deprecated: 12.0, message: "AsyncCompatibilityKit is only useful when targeting macOS versions earlier than 12") 13 | public extension URLSession { 14 | /// Start a data task with a URL using async/await. 15 | /// - parameter url: The URL to send a request to. 16 | /// - returns: A tuple containing the binary `Data` that was downloaded, 17 | /// as well as a `URLResponse` representing the server's response. 18 | /// - throws: Any error encountered while performing the data task. 19 | func data(from url: URL) async throws -> (Data, URLResponse) { 20 | try await data(for: URLRequest(url: url)) 21 | } 22 | 23 | /// Start a data task with a `URLRequest` using async/await. 24 | /// - parameter request: The `URLRequest` that the data task should perform. 25 | /// - returns: A tuple containing the binary `Data` that was downloaded, 26 | /// as well as a `URLResponse` representing the server's response. 27 | /// - throws: Any error encountered while performing the data task. 28 | func data(for request: URLRequest) async throws -> (Data, URLResponse) { 29 | var dataTask: URLSessionDataTask? 30 | let onCancel = { dataTask?.cancel() } 31 | 32 | return try await withTaskCancellationHandler( 33 | operation: { 34 | try await withCheckedThrowingContinuation { continuation in 35 | dataTask = self.dataTask(with: request) { data, response, error in 36 | guard let data = data, let response = response else { 37 | let error = error ?? URLError(.badServerResponse) 38 | return continuation.resume(throwing: error) 39 | } 40 | 41 | continuation.resume(returning: (data, response)) 42 | } 43 | 44 | dataTask?.resume() 45 | } 46 | }, 47 | onCancel: { 48 | onCancel() 49 | } 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Source/Networking/UploadManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploadManager.swift 3 | // PPPC Utility 4 | // 5 | // Created by Kyle Hammond on 11/3/23. 6 | // Copyright © 2023 Jamf. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OSLog 11 | 12 | struct UploadManager { 13 | let serverURL: String 14 | 15 | let logger = Logger.UploadManager 16 | 17 | struct VerificationInfo { 18 | let mustSign: Bool 19 | let organization: String 20 | } 21 | 22 | enum VerificationError: Error { 23 | case anyError(String) 24 | } 25 | 26 | func verifyConnection(authManager: NetworkAuthManager, completionHandler: @escaping (Result) -> Void) { 27 | logger.info("Checking connection to Jamf Pro server") 28 | 29 | Task { 30 | let networking = JamfProAPIClient(serverUrlString: serverURL, tokenManager: authManager) 31 | let result: Result 32 | 33 | do { 34 | let version = try await networking.getJamfProVersion() 35 | 36 | // Must sign if Jamf Pro is less than v10.7.1 37 | let mustSign = (version.semantic() < SemanticVersion(major: 10, minor: 7, patch: 1)) 38 | 39 | let orgName = try await networking.getOrganizationName() 40 | 41 | result = .success(VerificationInfo(mustSign: mustSign, organization: orgName)) 42 | } catch is AuthError { 43 | logger.error("Invalid credentials.") 44 | result = .failure(VerificationError.anyError("Invalid credentials.")) 45 | } catch { 46 | logger.error("Jamf Pro server is unavailable.") 47 | result = .failure(VerificationError.anyError("Jamf Pro server is unavailable.")) 48 | } 49 | 50 | completionHandler(result) 51 | } 52 | } 53 | 54 | func upload(profile: TCCProfile, authMgr: NetworkAuthManager, siteInfo: (String, String)?, signingIdentity: SigningIdentity?, completionHandler: @escaping (Error?) -> Void) { 55 | logger.info("Uploading profile: \(profile.displayName, privacy: .public)") 56 | 57 | let networking = JamfProAPIClient(serverUrlString: serverURL, tokenManager: authMgr) 58 | Task { 59 | let success: Error? 60 | var identity: SecIdentity? 61 | if let signingIdentity = signingIdentity { 62 | logger.info("Signing profile with \(signingIdentity.displayName)") 63 | identity = signingIdentity.reference 64 | } 65 | 66 | do { 67 | let profileData = try profile.jamfProAPIData(signingIdentity: identity, site: siteInfo) 68 | 69 | _ = try await networking.upload(computerConfigProfile: profileData) 70 | 71 | success = nil 72 | logger.info("Uploaded successfully") 73 | } catch { 74 | logger.error("Error creating or uploading profile: \(error.localizedDescription)") 75 | success = error 76 | } 77 | 78 | DispatchQueue.main.async { 79 | completionHandler(success) 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Source/SecurityWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecurityWrapper.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | import Haversack 30 | 31 | struct SecurityWrapper { 32 | 33 | static func execute(block: () -> (OSStatus)) throws { 34 | let status = block() 35 | if status != 0 { 36 | throw NSError(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: nil) 37 | } 38 | } 39 | 40 | static func saveCredentials(username: String, password: String, server: String) throws { 41 | let haversack = Haversack() 42 | let item = InternetPasswordEntity() 43 | item.server = server 44 | item.account = username 45 | item.passwordData = password.data(using: .utf8) 46 | 47 | try haversack.save(item, itemSecurity: .standard, updateExisting: true) 48 | } 49 | 50 | static func removeCredentials(server: String, username: String) throws { 51 | let haversack = Haversack() 52 | let query = InternetPasswordQuery(server: server) 53 | .matching(account: username) 54 | 55 | try haversack.delete(where: query, treatNotFoundAsSuccess: true) 56 | } 57 | 58 | static func loadCredentials(server: String) throws -> (username: String, password: String)? { 59 | let haversack = Haversack() 60 | let query = InternetPasswordQuery(server: server) 61 | .returning([.attributes, .data]) 62 | 63 | if let item = try? haversack.first(where: query), 64 | let username = item.account, 65 | let passwordData = item.passwordData, 66 | let password = String(data: passwordData, encoding: .utf8) { 67 | return (username: username, password: password) 68 | } 69 | 70 | return nil 71 | } 72 | 73 | static func copyDesignatedRequirement(url: URL) throws -> String { 74 | let flags = SecCSFlags(rawValue: 0) 75 | var staticCode: SecStaticCode? 76 | var requirement: SecRequirement? 77 | var text: CFString? 78 | 79 | try execute { SecStaticCodeCreateWithPath(url as CFURL, flags, &staticCode) } 80 | try execute { SecCodeCopyDesignatedRequirement(staticCode!, flags, &requirement) } 81 | try execute { SecRequirementCopyString(requirement!, flags, &text) } 82 | 83 | return text! as String 84 | } 85 | 86 | static func sign(data: Data, using identity: SecIdentity) throws -> Data { 87 | 88 | var outputData: CFData? 89 | var encoder: CMSEncoder? 90 | try execute { CMSEncoderCreate(&encoder) } 91 | try execute { CMSEncoderAddSigners(encoder!, identity) } 92 | try execute { CMSEncoderAddSignedAttributes(encoder!, .attrSmimeCapabilities) } 93 | try execute { CMSEncoderUpdateContent(encoder!, (data as NSData).bytes, data.count) } 94 | try execute { CMSEncoderCopyEncodedContent(encoder!, &outputData) } 95 | 96 | return outputData! as Data 97 | } 98 | 99 | static func loadSigningIdentities() throws -> [SigningIdentity] { 100 | let haversack = Haversack() 101 | let query = IdentityQuery().matching(mustBeValidOnDate: Date()).returning(.reference) 102 | 103 | let identities = try haversack.search(where: query) 104 | 105 | return identities.compactMap { 106 | guard let secIdentity = $0.reference else { 107 | return nil 108 | } 109 | 110 | let name = try? getCertificateCommonName(for: secIdentity) 111 | return SigningIdentity(name: name ?? "Unknown \(secIdentity.hashValue)", 112 | reference: secIdentity) 113 | } 114 | } 115 | 116 | static func getCertificateCommonName(for identity: SecIdentity) throws -> String { 117 | var certificate: SecCertificate? 118 | var commonName: CFString? 119 | try execute { SecIdentityCopyCertificate(identity, &certificate) } 120 | try execute { SecCertificateCopyCommonName(certificate!, &commonName) } 121 | return commonName! as String 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Source/SwiftUI/UploadInfoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploadInfoView.swift 3 | // PPPC Utility 4 | // 5 | // SPDX-License-Identifier: MIT 6 | // Copyright (c) 2023 Jamf Software 7 | 8 | import OSLog 9 | import SwiftUI 10 | 11 | struct UploadInfoView: View { 12 | /// The signing identities available to be used. 13 | let signingIdentities: [SigningIdentity] 14 | /// Function to call when this view needs to be removed 15 | let dismissAction: (() -> Void)? 16 | 17 | // Communicate this info to the user 18 | @State private var warningInfo: String? 19 | @State private var networkOperationInfo: String? 20 | /// Must sign the profile if Jamf Pro is less than v10.7.1 21 | @State private var mustSign = false 22 | /// The hash of connection info that has been verified with a succesful connection 23 | @State private var verifiedConnectionHash: Int = 0 24 | 25 | // MARK: User entry fields 26 | @AppStorage("jamfProServer") private var serverURL = "https://" 27 | @AppStorage("organization") private var organization = "" 28 | @AppStorage("authType") private var authType = AuthenticationType.clientCredentials 29 | 30 | @State private var username = "" 31 | @State private var password = "" 32 | @State private var saveToKeychain: Bool = true 33 | @State private var payloadName = "" 34 | @State private var payloadId = UUID().uuidString 35 | @State private var payloadDescription = "" 36 | @State private var signingId: SigningIdentity? 37 | @State private var useSite: Bool = false 38 | @State private var siteId: Int = -1 39 | @State private var siteName: String = "" 40 | 41 | let logger = Logger.UploadInfoView 42 | 43 | /// The type of authentication the user wants to use. 44 | /// 45 | /// `String` type so it can be saved with `@AppStorage` above 46 | enum AuthenticationType: String { 47 | case basicAuth 48 | case clientCredentials 49 | } 50 | 51 | let intFormatter: NumberFormatter = { 52 | let formatter = NumberFormatter() 53 | formatter.numberStyle = .none 54 | return formatter 55 | }() 56 | 57 | var body: some View { 58 | VStack { 59 | Form { 60 | TextField("Jamf Pro Server *:", text: $serverURL) 61 | Picker("Authorization Type:", selection: $authType) { 62 | Text("Basic/Bearer Auth").tag(AuthenticationType.basicAuth) 63 | Text("Client Credentials (v10.49+):").tag(AuthenticationType.clientCredentials) 64 | } 65 | TextField(authType == .basicAuth ? "Username *:" : "Client ID *:", text: $username) 66 | SecureField(authType == .basicAuth ? "Password *:" : "Client Secret *:", text: $password) 67 | 68 | HStack { 69 | Toggle("Save in Keychain", isOn: $saveToKeychain) 70 | .help("Store the username & password or client id & secret in the login keychain") 71 | if verifiedConnection { 72 | Spacer() 73 | Text("✔️ Verified") 74 | .font(.footnote) 75 | } 76 | } 77 | Divider() 78 | .padding(.vertical) 79 | 80 | TextField("Organization *:", text: $organization) 81 | TextField("Payload Name *:", text: $payloadName) 82 | TextField("Payload Identifier *:", text: $payloadId) 83 | TextField("Payload Description:", text: $payloadDescription) 84 | Picker("Signing Identity:", selection: $signingId) { 85 | Text("Profile signed by server").tag(nil as SigningIdentity?) 86 | ForEach(signingIdentities, id: \.self) { identity in 87 | Text(identity.displayName).tag(identity) 88 | } 89 | } 90 | .disabled(!mustSign) 91 | Toggle("Use Site", isOn: $useSite) 92 | TextField("Site ID", value: $siteId, formatter: intFormatter) 93 | .disabled(!useSite) 94 | TextField("Site Name", text: $siteName) 95 | .disabled(!useSite) 96 | } 97 | .padding(.bottom) 98 | 99 | if let warning = warningInfo { 100 | Text(warning) 101 | .font(.headline) 102 | .foregroundColor(.red) 103 | } 104 | if let networkInfo = networkOperationInfo { 105 | HStack { 106 | Text(networkInfo) 107 | .font(.headline) 108 | ProgressView() 109 | .padding(.leading) 110 | } 111 | } 112 | 113 | HStack { 114 | Spacer() 115 | 116 | Button("Cancel") { 117 | dismissView() 118 | } 119 | .keyboardShortcut(.cancelAction) 120 | 121 | Button(verifiedConnection ? "Upload" : "Check connection") { 122 | if verifiedConnection { 123 | performUpload() 124 | } else { 125 | verifyConnection() 126 | } 127 | } 128 | .keyboardShortcut(.defaultAction) 129 | .disabled(!buttonEnabled()) 130 | } 131 | } 132 | .padding() 133 | .frame(minWidth: 450) 134 | .background(Color(.windowBackgroundColor)) 135 | .onAppear { 136 | // Load keychain values 137 | if let creds = try? SecurityWrapper.loadCredentials(server: serverURL) { 138 | username = creds.username 139 | password = creds.password 140 | } 141 | 142 | // Use model payload values if it was imported 143 | if let tccProfile = Model.shared.importedTCCProfile { 144 | organization = tccProfile.organization 145 | payloadName = tccProfile.displayName 146 | payloadDescription = tccProfile.payloadDescription 147 | payloadId = tccProfile.identifier 148 | } 149 | } 150 | } 151 | 152 | /// Creates a hash of the currently entered connection info 153 | var hashOfConnectionInfo: Int { 154 | var hasher = Hasher() 155 | hasher.combine(serverURL) 156 | hasher.combine(username) 157 | hasher.combine(password) 158 | hasher.combine(authType) 159 | return hasher.finalize() 160 | } 161 | 162 | /// Compare the last verified connection hash with the current hash of connection info 163 | var verifiedConnection: Bool { 164 | verifiedConnectionHash == hashOfConnectionInfo 165 | } 166 | 167 | func buttonEnabled() -> Bool { 168 | if verifiedConnection { 169 | return payloadInfoPassesValidation() 170 | } 171 | return connectionInfoPassesValidation() 172 | } 173 | 174 | private func warning(_ info: StaticString, shouldDisplay: Bool) { 175 | if shouldDisplay { 176 | logger.info("\(info)") 177 | warningInfo = "\(info)" 178 | } 179 | } 180 | 181 | /// Does some simple validation of the user-entered connection info 182 | /// 183 | /// The `setWarningInfo` parameter is optional, and should only be set to `true` during 184 | /// actions triggered by the user. This function can be called with `false` (or no parameters) 185 | /// from SwiftUI's `body` function to enable/disable controls. 186 | /// - Parameter setWarningInfo: Whether to set the warning text so the user knows something needs to be updated. Default is `false`. 187 | /// - Returns: True if the user entered connection info passes simple local validation 188 | func connectionInfoPassesValidation(setWarningInfo: Bool = false) -> Bool { 189 | guard !serverURL.isEmpty else { 190 | warning("Server URL not set", shouldDisplay: setWarningInfo) 191 | // Future on macOS 12+: focus on serverURL field 192 | return false 193 | } 194 | 195 | guard let url = URL(string: serverURL), 196 | url.scheme == "http" || url.scheme == "https" else { 197 | warning("Invalid Jamf Pro Server URL", shouldDisplay: setWarningInfo) 198 | // Future on macOS 12+: focus on serverURL field 199 | return false 200 | } 201 | 202 | if authType == .basicAuth { 203 | guard !username.isEmpty, !password.isEmpty else { 204 | warning("Username or password not set", shouldDisplay: setWarningInfo) 205 | // Future on macOS 12+: focus on username or password field 206 | return false 207 | } 208 | 209 | guard username.firstIndex(of: ":") == nil else { 210 | warning("Username cannot contain a colon", shouldDisplay: setWarningInfo) 211 | // Future on macOS 12+: focus on username field 212 | return false 213 | } 214 | } else { 215 | guard !username.isEmpty, !password.isEmpty else { 216 | warning("Client ID or secret not set", shouldDisplay: setWarningInfo) 217 | // Future on macOS 12+: focus on username or password field 218 | return false 219 | } 220 | } 221 | 222 | if setWarningInfo { 223 | warningInfo = nil 224 | } 225 | return true 226 | } 227 | 228 | /// Does some simple validation of the user-entered payload info 229 | /// 230 | /// The `setWarningInfo` parameter is optional, and should only be set to `true` during 231 | /// actions triggered by the user. This function can be called with `false` (or no parameters) 232 | /// from SwiftUI's `body` function to enable/disable controls. 233 | /// - Parameter setWarningInfo: Whether to set the warning text so the user knows something needs to be updated. Default is `false`. 234 | /// - Returns: True if the user entered payload info passes simple local validation 235 | func payloadInfoPassesValidation(setWarningInfo: Bool = false) -> Bool { 236 | guard !organization.isEmpty else { 237 | warning("Must provide an organization name", shouldDisplay: setWarningInfo) 238 | // Future on macOS 12+: focus on organization field 239 | return false 240 | } 241 | 242 | guard !payloadId.isEmpty else { 243 | warning("Must provide a payload identifier", shouldDisplay: setWarningInfo) 244 | // Future on macOS 12+: focus on payload ID field 245 | return false 246 | } 247 | 248 | guard !payloadName.isEmpty else { 249 | warning("Must provide a payload name", shouldDisplay: setWarningInfo) 250 | // Future on macOS 12+: focus on payloadName field 251 | return false 252 | } 253 | 254 | guard useSite == false || (useSite == true && siteId != -1 && !siteName.isEmpty) else { 255 | warning("Must provide both an ID and name for the site", shouldDisplay: setWarningInfo) 256 | // Future on macOS 12+: focus on siteId or siteName field 257 | return false 258 | } 259 | 260 | if setWarningInfo { 261 | warningInfo = nil 262 | } 263 | return true 264 | } 265 | 266 | func makeAuthManager() -> NetworkAuthManager { 267 | if authType == .basicAuth { 268 | return NetworkAuthManager(username: username, password: password) 269 | } 270 | 271 | return NetworkAuthManager(clientId: username, clientSecret: password) 272 | } 273 | 274 | func verifyConnection() { 275 | guard connectionInfoPassesValidation(setWarningInfo: true) else { 276 | return 277 | } 278 | 279 | networkOperationInfo = "Checking Jamf Pro server" 280 | 281 | let uploadMgr = UploadManager(serverURL: serverURL) 282 | uploadMgr.verifyConnection(authManager: makeAuthManager()) { result in 283 | if case .success(let success) = result { 284 | mustSign = success.mustSign 285 | organization = success.organization 286 | verifiedConnectionHash = hashOfConnectionInfo 287 | if saveToKeychain { 288 | do { 289 | try SecurityWrapper.saveCredentials(username: username, 290 | password: password, 291 | server: serverURL) 292 | } catch { 293 | logger.error("Failed to save credentials with error: \(error.localizedDescription)") 294 | } 295 | } 296 | // Future on macOS 12+: focus on Payload Name field 297 | } else if case .failure(let failure) = result, 298 | case .anyError(let errorString) = failure { 299 | warningInfo = errorString 300 | verifiedConnectionHash = 0 301 | } 302 | 303 | networkOperationInfo = nil 304 | } 305 | } 306 | 307 | private func dismissView() { 308 | if !saveToKeychain { 309 | try? SecurityWrapper.removeCredentials(server: serverURL, username: username) 310 | } 311 | 312 | if let dismiss = dismissAction { 313 | dismiss() 314 | } 315 | } 316 | 317 | func performUpload() { 318 | guard connectionInfoPassesValidation(setWarningInfo: true) else { 319 | return 320 | } 321 | 322 | guard payloadInfoPassesValidation(setWarningInfo: true) else { 323 | return 324 | } 325 | 326 | let profile = Model.shared.exportProfile(organization: organization, 327 | identifier: payloadId, 328 | displayName: payloadName, 329 | payloadDescription: payloadDescription) 330 | 331 | networkOperationInfo = "Uploading '\(profile.displayName)'..." 332 | 333 | var siteIdAndName: (String, String)? 334 | if useSite { 335 | if siteId != -1 && !siteName.isEmpty { 336 | siteIdAndName = ("\(siteId)", siteName) 337 | } 338 | } 339 | 340 | let uploadMgr = UploadManager(serverURL: serverURL) 341 | uploadMgr.upload(profile: profile, 342 | authMgr: makeAuthManager(), 343 | siteInfo: siteIdAndName, 344 | signingIdentity: mustSign ? signingId : nil) { possibleError in 345 | if let error = possibleError { 346 | warningInfo = error.localizedDescription 347 | } else { 348 | Alert().display(header: "Success", message: "Profile uploaded succesfully") 349 | dismissView() 350 | } 351 | networkOperationInfo = nil 352 | } 353 | } 354 | } 355 | 356 | #Preview { 357 | UploadInfoView(signingIdentities: [], 358 | dismissAction: nil) 359 | } 360 | -------------------------------------------------------------------------------- /Source/TCCProfileImporter/TCCProfileConfigurationPanel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TCCProfileViewController.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2018 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import AppKit 29 | import Foundation 30 | 31 | class TCCProfileConfigurationPanel { 32 | /// Load TCC Profile data from file 33 | /// 34 | /// - Parameter completion: TCCProfileImportCompletion - success with TCCProfile or failure with TCCProfileImport Error 35 | func loadTCCProfileFromFile(importer: TCCProfileImporter, window: NSWindow, _ completion: @escaping TCCProfileImportCompletion) { 36 | let openPanel = NSOpenPanel.init() 37 | openPanel.allowedFileTypes = ["mobileconfig", "plist"] 38 | openPanel.allowsMultipleSelection = false 39 | openPanel.canChooseDirectories = false 40 | openPanel.canCreateDirectories = false 41 | openPanel.canChooseFiles = true 42 | openPanel.title = "Open TCCProfile File" 43 | 44 | openPanel.beginSheetModal(for: window) { (response) in 45 | if response != .OK { 46 | completion(.failure(.cancelled)) 47 | } else { 48 | if let result = openPanel.url { 49 | importer.decodeTCCProfile(fileUrl: result) { tccProfileResult in 50 | return completion(tccProfileResult) 51 | } 52 | } else { 53 | completion(.failure(TCCProfileImportError.unableToOpenFile)) 54 | } 55 | } 56 | } 57 | 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Source/TCCProfileImporter/TCCProfileImportError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigProfileImportError.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2019 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | public enum TCCProfileImportError: Error { 30 | case cancelled 31 | case unableToOpenFile 32 | case decodeProfileError 33 | case invalidProfileFile(description: String) 34 | case emptyFields(description: String) 35 | } 36 | 37 | extension TCCProfileImportError: LocalizedError { 38 | public var errorDescription: String? { 39 | switch self { 40 | case .cancelled: 41 | return "Cancelled the import." 42 | case .unableToOpenFile: 43 | return "Unable to open file. Please make sure that file is correct and try again." 44 | case .decodeProfileError: 45 | return "Unable to read configuration profile. Please make sure the file is correct and try again." 46 | case .invalidProfileFile(let description): 47 | return "Invalid TCC Profile. Please make sure that required keys are inside profile: \(description)" 48 | case .emptyFields(let description): 49 | return "Unable to proceed. The following fields are required: \(description)" 50 | } 51 | } 52 | 53 | var isCancelled: Bool { 54 | switch self { 55 | case .cancelled: 56 | return true 57 | default: 58 | return false 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Source/TCCProfileImporter/TCCProfileImporter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TCCProfileImporter.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2019 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | 30 | typealias TCCProfileImportResult = Result 31 | typealias TCCProfileImportCompletion = ((TCCProfileImportResult) -> Void) 32 | 33 | /// Load tcc profiles 34 | public class TCCProfileImporter { 35 | 36 | // MARK: Load TCCProfile 37 | 38 | /// Mapping & Decoding tcc profile 39 | /// 40 | /// - Parameter fileUrl: path with a file to load, completion: TCCProfileImportCompletion - success with TCCProfile or failure with TCCProfileImport Error 41 | func decodeTCCProfile(data: Data, _ completion: @escaping TCCProfileImportCompletion) { 42 | do { 43 | // Note that parse will ignore the signing portion of the data 44 | let tccProfile = try TCCProfile.parse(from: data) 45 | return completion(.success(tccProfile)) 46 | } catch TCCProfile.ParseError.failedToCreateDecoder { 47 | return completion(.failure(.decodeProfileError)) 48 | } catch let DecodingError.keyNotFound(codingKey, _) { 49 | return completion(TCCProfileImportResult.failure(.invalidProfileFile(description: codingKey.stringValue))) 50 | } catch let DecodingError.typeMismatch(type, context) { 51 | let errorDescription = "Type \(type) mismatch: \(context.debugDescription) codingPath: \(context.codingPath)" 52 | return completion(.failure(.invalidProfileFile(description: errorDescription))) 53 | } catch let error as NSError { 54 | let errorDescription = error.userInfo["NSDebugDescription"] as? String 55 | return completion(.failure(.invalidProfileFile(description: errorDescription ?? error.localizedDescription))) 56 | } 57 | } 58 | 59 | /// Mapping & Decoding tcc profile 60 | /// 61 | /// - Parameter fileUrl: path with a file to load, completion: TCCProfileImportCompletion - success with TCCProfile or failure with TCCProfileImport Error 62 | func decodeTCCProfile(fileUrl: URL, _ completion: @escaping TCCProfileImportCompletion) { 63 | let data: Data 64 | do { 65 | data = try Data(contentsOf: fileUrl) 66 | return decodeTCCProfile(data: data, completion) 67 | } catch { 68 | return completion(.failure(.unableToOpenFile)) 69 | } 70 | 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Source/View Controllers/OpenViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenViewController.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2018 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Cocoa 29 | 30 | class OpenViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate { 31 | 32 | var completionBlock: (([LoadExecutableResult]) -> Void)? 33 | 34 | var observers: [NSKeyValueObservation] = [] 35 | 36 | @objc dynamic var current: Executable? 37 | @objc dynamic var choices: [Executable] = [] 38 | 39 | @IBOutlet var choicesAC: NSArrayController! 40 | 41 | override func viewWillAppear() { 42 | super.viewWillAppear() 43 | // Reload executables 44 | current = Model.shared.current 45 | if let value = current { 46 | choices = Model.shared.getAppleEventChoices(executable: value) 47 | } 48 | } 49 | 50 | func tableView(_ tableView: NSTableView, selectionIndexesForProposedSelection proposedSelectionIndexes: IndexSet) -> IndexSet { 51 | DispatchQueue.main.async { 52 | guard let index = proposedSelectionIndexes.first else { return } 53 | self.completionBlock?([.success(self.choices[index])]) 54 | self.dismiss(self) 55 | } 56 | return proposedSelectionIndexes 57 | } 58 | 59 | @IBAction func prompt(_ sender: NSButton) { 60 | let block = completionBlock 61 | let panel = NSOpenPanel() 62 | panel.allowsMultipleSelection = true 63 | panel.allowedFileTypes = [ kUTTypeBundle, kUTTypeUnixExecutable ] as [String] 64 | panel.directoryURL = URL(fileURLWithPath: "/Applications", isDirectory: true) 65 | panel.begin { response in 66 | if response == .OK { 67 | var selections: [LoadExecutableResult] = [] 68 | panel.urls.forEach { 69 | Model.shared.loadExecutable(url: $0) { result in 70 | selections.append(result) 71 | } 72 | } 73 | block?(selections) 74 | } 75 | } 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /Source/View Controllers/SaveViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SaveViewController.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2018 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Cocoa 29 | import OSLog 30 | 31 | class SaveViewController: NSViewController { 32 | 33 | private static var saveProfileKVOContext = 0 34 | 35 | @objc dynamic var isReadyToSave: Bool = false 36 | 37 | @objc dynamic var payloadName: String! { 38 | didSet { 39 | updateIsReadyToSave() 40 | } 41 | } 42 | 43 | @objc dynamic var payloadIdentifier: String! { 44 | didSet { 45 | updateIsReadyToSave() 46 | } 47 | } 48 | 49 | @objc dynamic var payloadDescription: String! { 50 | didSet { 51 | updateIsReadyToSave() 52 | } 53 | } 54 | 55 | @IBOutlet weak var payloadNameLabel: NSTextField! 56 | 57 | @IBOutlet weak var organizationLabel: NSTextField! 58 | @IBOutlet weak var identitiesPopUp: NSPopUpButton! 59 | @IBOutlet var identitiesPopUpAC: NSArrayController! 60 | @IBOutlet weak var saveButton: NSButton! 61 | 62 | let logger = Logger.SaveViewController 63 | 64 | var defaultsController = NSUserDefaultsController.shared 65 | 66 | func updateIsReadyToSave() { 67 | guard isReadyToSave != ( 68 | !organizationLabel.stringValue.isEmpty 69 | && (payloadName != nil) 70 | && !payloadName.isEmpty 71 | && (payloadIdentifier != nil) 72 | && !payloadIdentifier.isEmpty ) else { return } 73 | isReadyToSave = !isReadyToSave 74 | } 75 | 76 | @IBAction func savePressed(_ sender: NSButton) { 77 | let panel = NSSavePanel() 78 | panel.allowedFileTypes = ["mobileconfig"] 79 | panel.nameFieldStringValue = payloadName 80 | if let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first { 81 | panel.directoryURL = URL(fileURLWithPath: path, isDirectory: true) 82 | } 83 | 84 | panel.begin { response in 85 | if response == .OK { 86 | // Let the save panel fully close itself before doing any work that may require keychain access. 87 | DispatchQueue.main.async { 88 | self.saveTo(url: panel.url!) 89 | } 90 | } 91 | } 92 | } 93 | 94 | override func viewDidLoad() { 95 | super.viewDidLoad() 96 | payloadIdentifier = UUID().uuidString 97 | do { 98 | var identities = try SecurityWrapper.loadSigningIdentities() 99 | identities.insert(SigningIdentity(name: "Not signed", reference: nil), at: 0) 100 | identitiesPopUpAC.add(contentsOf: identities) 101 | } catch { 102 | logger.error("Error loading identities: \(error)") 103 | } 104 | 105 | loadImportedTCCProfileInfo() 106 | } 107 | 108 | override func viewWillAppear() { 109 | super.viewWillAppear() 110 | defaultsController.addObserver(self, forKeyPath: "values.organization", options: [.new], context: &SaveViewController.saveProfileKVOContext) 111 | if !organizationLabel.stringValue.isEmpty { 112 | payloadNameLabel.becomeFirstResponder() 113 | } 114 | } 115 | 116 | override func viewWillDisappear() { 117 | super.viewWillDisappear() 118 | defaultsController.removeObserver(self, forKeyPath: "values.organization", context: &SaveViewController.saveProfileKVOContext) 119 | } 120 | 121 | // swiftlint:disable:next block_based_kvo 122 | override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { 123 | if context == &SaveViewController.saveProfileKVOContext { 124 | updateIsReadyToSave() 125 | } else { 126 | super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) 127 | } 128 | } 129 | 130 | func saveTo(url: URL) { 131 | logger.info("Saving to \(url, privacy: .public)") 132 | let model = Model.shared 133 | let profile = model.exportProfile(organization: organizationLabel.stringValue, 134 | identifier: payloadIdentifier, 135 | displayName: payloadName, 136 | payloadDescription: payloadDescription ?? payloadName) 137 | do { 138 | var outputData = try profile.xmlData() 139 | if let identity = identitiesPopUpAC.selectedObjects.first as? SigningIdentity, let ref = identity.reference { 140 | logger.info("Signing profile with \(identity.displayName)") 141 | outputData = try SecurityWrapper.sign(data: outputData, using: ref) 142 | } 143 | try outputData.write(to: url) 144 | logger.info("Saved successfully") 145 | } catch { 146 | logger.error("Error: \(error)") 147 | } 148 | self.dismiss(nil) 149 | } 150 | 151 | func loadImportedTCCProfileInfo() { 152 | let model = Model.shared 153 | 154 | if let tccProfile = model.importedTCCProfile { 155 | organizationLabel.stringValue = tccProfile.organization 156 | payloadName = tccProfile.displayName 157 | payloadDescription = tccProfile.payloadDescription 158 | payloadIdentifier = tccProfile.identifier 159 | } 160 | } 161 | 162 | } 163 | -------------------------------------------------------------------------------- /Source/Views/Alert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Alert.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2020 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Cocoa 29 | 30 | class Alert: NSObject { 31 | func display(header: String, message: String) { 32 | DispatchQueue.main.async { 33 | let dialog: NSAlert = NSAlert() 34 | dialog.messageText = header 35 | dialog.informativeText = message 36 | dialog.alertStyle = NSAlert.Style.warning 37 | dialog.addButton(withTitle: "OK") 38 | dialog.runModal() 39 | } 40 | } 41 | 42 | /// Displays a message with a cancel button and returns true if OK was pressed 43 | /// Assumes this method is called from the main queue. 44 | /// 45 | /// - Parameters: 46 | /// - header: The header message 47 | /// - message: The message body 48 | /// - Returns: True if the ok button was pressed 49 | func displayWithCancel(header: String, message: String) -> Bool { 50 | let dialog: NSAlert = NSAlert() 51 | dialog.messageText = header 52 | dialog.informativeText = message 53 | dialog.alertStyle = NSAlert.Style.warning 54 | dialog.addButton(withTitle: "OK") 55 | dialog.addButton(withTitle: "Cancel") 56 | let response = dialog.runModal() 57 | let okPressed = (response.rawValue == 1000) 58 | return okPressed 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /Source/Views/FlippedClipView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlippedClipView.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2019 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Cocoa 29 | 30 | class FlippedClipView: NSClipView { 31 | override var isFlipped: Bool { 32 | return true 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Source/Views/InfoButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlippedClipView.swift 3 | // PPPC Utility 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2019 Jamf Software 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Cocoa 29 | 30 | class InfoButton: NSButton { 31 | private var helpMessage: String = "" 32 | 33 | func setHelpMessage(_ message: String?) { 34 | self.helpMessage = message ?? "" 35 | } 36 | 37 | func showHelpMessage() { 38 | NSHelpManager.shared.setContextHelp(NSAttributedString(string: helpMessage), for: self) 39 | NSHelpManager.shared.showContextHelp(for: self, locationHint: NSEvent.mouseLocation) 40 | NSHelpManager.shared.removeContextHelp(for: self) 41 | } 42 | } 43 | --------------------------------------------------------------------------------