diff --git a/.github/workflows/fleet-desktop-macos-build.yml b/.github/workflows/fleet-desktop-macos-build.yml index 8c04d8a554c..cf4d7bbc8c2 100644 --- a/.github/workflows/fleet-desktop-macos-build.yml +++ b/.github/workflows/fleet-desktop-macos-build.yml @@ -1,8 +1,20 @@ name: Build Fleet Desktop (macOS) -# Builds the native macOS Fleet Desktop app (apps/fleet-desktop-macos/), code signs -# and notarizes it with Fleet's Developer ID certificates, and uploads the signed +# Builds the native macOS Fleet Desktop app (apps/fleet-desktop-macos/) and its +# embedded Platform SSO extension (FleetPSSOExtension.appex), code signs and +# notarizes them with Fleet's Developer ID certificates, and uploads the signed # .pkg as a workflow artifact. No GitHub Release is created. +# +# The app and extension carry managed Associated Domains entitlements +# (com.apple.developer.associated-domains{,.mdm-managed}). Those are restricted +# entitlements: codesign only honors them when a Developer ID provisioning +# profile that grants them is embedded in the bundle. The profiles are provided +# as base64 repo secrets (never committed) and embedded at sign time — see the +# README's "Signing secrets" section. +# +# This workflow always signs and notarizes. If the certs or provisioning +# profiles are unavailable (e.g. a fork PR that can't read secrets), it fails +# loudly rather than producing an unsigned artifact. on: push: @@ -36,6 +48,8 @@ env: # of Fleet's macOS artifacts (orbit Fleet Desktop, fleetd-base.pkg). APPLICATION_SIGNING_IDENTITY_SHA1: 604D877399AAEB7630A78B84F288E2D28A2EDE42 INSTALLER_SIGNING_IDENTITY_SHA1: 4608F71FB42E1845C7FC9B2D2B6A7A8D11BBD940 + # Embedded SSO extension bundle (relative to Fleet Desktop.app/Contents). + APPEX_REL_PATH: PlugIns/FleetPSSOExtension.appex jobs: build: @@ -52,7 +66,7 @@ jobs: with: persist-credentials: false - - name: Build app and create pkg + - name: Build app (with embedded extension) and create pkg run: | chmod +x build.sh build-pkg.sh ./build-pkg.sh @@ -69,7 +83,7 @@ jobs: security default-keychain -s build.keychain security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - # Developer ID Application certificate — signs the .app (codesign). + # Developer ID Application certificate — signs the .app/.appex (codesign). echo "$APPLE_APPLICATION_CERTIFICATE" | base64 --decode > application.p12 security import application.p12 -k build.keychain -P "$APPLE_APPLICATION_CERTIFICATE_PASSWORD" -T /usr/bin/codesign rm application.p12 @@ -82,24 +96,71 @@ jobs: security set-key-partition-list -S apple-tool:,apple:,codesign:,productsign: -s -k "$KEYCHAIN_PASSWORD" build.keychain security find-identity -vv - - name: Code sign app + - name: Embed provisioning profiles + env: + APPLE_FLEET_DESKTOP_APP_PROFILE_B64: ${{ secrets.APPLE_FLEET_DESKTOP_APP_PROFILE_B64 }} + APPLE_PSSO_EXT_PROFILE_B64: ${{ secrets.APPLE_PSSO_EXT_PROFILE_B64 }} + run: | + APP="build/Fleet Desktop.app" + APPEX="$APP/Contents/$APPEX_REL_PATH" + + if [ -z "$APPLE_FLEET_DESKTOP_APP_PROFILE_B64" ] || [ -z "$APPLE_PSSO_EXT_PROFILE_B64" ]; then + echo "::error::Missing provisioning profile secrets (APPLE_FLEET_DESKTOP_APP_PROFILE_B64 / APPLE_PSSO_EXT_PROFILE_B64). The app and extension carry restricted Associated Domains entitlements that codesign cannot honor without them." + exit 1 + fi + + # Developer ID profiles authorizing the restricted entitlements. + echo "$APPLE_PSSO_EXT_PROFILE_B64" | base64 --decode > "$APPEX/Contents/embedded.provisionprofile" + echo "$APPLE_FLEET_DESKTOP_APP_PROFILE_B64" | base64 --decode > "$APP/Contents/embedded.provisionprofile" + + - name: Verify profiles authorize the signing certificate + run: | + APP="build/Fleet Desktop.app" + APPEX="$APP/Contents/$APPEX_REL_PATH" + + # AMFI requires the signing certificate to be listed in the embedded + # profile's DeveloperCertificates, or it SIGKILLs the app at launch. + # codesign, Gatekeeper, and notarization all pass regardless — so + # without this check a profile cut against the wrong cert produces a + # signed, notarized pkg that silently won't launch. Even two certs from + # the same team will result in a broken, unusable app - they must be the + # same cert + check='import sys,plistlib,hashlib; pl=plistlib.loads(sys.stdin.buffer.read()); h=[hashlib.sha1(bytes(c)).hexdigest().upper() for c in pl.get("DeveloperCertificates",[])]; print(" authorizes:",h); sys.exit(0 if sys.argv[1].upper() in h else 1)' + for prof in "$APPEX/Contents/embedded.provisionprofile" "$APP/Contents/embedded.provisionprofile"; do + echo "Checking $prof" + if ! security cms -D -i "$prof" | python3 -c "$check" "$APPLICATION_SIGNING_IDENTITY_SHA1"; then + echo "::error::$prof does not authorize signing certificate $APPLICATION_SIGNING_IDENTITY_SHA1. AMFI will SIGKILL the app at launch (notarization does NOT catch this). Regenerate the Developer ID provisioning profile selecting that certificate." + exit 1 + fi + done + + - name: Code sign app and extension run: | - BINARY_PATH="build/Fleet Desktop.app/Contents/MacOS/FleetDesktop" + APP="build/Fleet Desktop.app" + APPEX="$APP/Contents/$APPEX_REL_PATH" - # Sign the universal binary first, then the bundle (no --deep). + # Sign inside-out: the embedded extension first, then the host app. + # Each bundle is sealed with its own entitlements + embedded profile. + codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \ + --options runtime --timestamp "$APPEX/Contents/MacOS/FleetPSSOExtension" codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \ - --options runtime --timestamp "$BINARY_PATH" - codesign --verify --verbose "$BINARY_PATH" + --options runtime --timestamp \ + --entitlements FleetPSSOExtension/FleetPSSOExtension.entitlements "$APPEX" codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \ - --options runtime --timestamp "build/Fleet Desktop.app" + --options runtime --timestamp "$APP/Contents/MacOS/FleetDesktop" + codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \ + --options runtime --timestamp \ + --entitlements FleetDesktop/FleetDesktop.entitlements "$APP" - codesign --verify --deep --strict --verbose=2 "build/Fleet Desktop.app" - codesign --display --verbose=4 "build/Fleet Desktop.app" + codesign --verify --deep --strict --verbose=2 "$APP" + codesign --display --verbose=4 "$APP" + codesign --display --entitlements - "$APPEX" - name: Rebuild pkg with signed app run: | - # build-pkg.sh reuses the already-signed app (ditto preserves the signature). + # build-pkg.sh reuses the already-signed app (ditto preserves the + # signature and the embedded, signed appex). ./build-pkg.sh - name: Sign pkg @@ -156,6 +217,7 @@ jobs: spctl --assess --type install --verbose "$PKG_PATH" - name: Cleanup keychain + if: always() run: security delete-keychain build.keychain || true - name: Upload pkg artifact diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/Contents.json b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 64dc11ee743..00000000000 --- a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "filename" : "icon_16x16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "icon_16x16@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "icon_32x32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "icon_32x32@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "icon_128x128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "icon_128x128@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "icon_256x256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "icon_256x256@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "icon_512x512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "icon_512x512@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_128x128.png deleted file mode 100644 index 92eb8c79b34..00000000000 Binary files a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_128x128.png and /dev/null differ diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png deleted file mode 100644 index 0025cb9c9e9..00000000000 Binary files a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png and /dev/null differ diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_16x16.png deleted file mode 100644 index 40f69c7c242..00000000000 Binary files a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_16x16.png and /dev/null differ diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png deleted file mode 100644 index 5c65259f389..00000000000 Binary files a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png and /dev/null differ diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_256x256.png deleted file mode 100644 index 0025cb9c9e9..00000000000 Binary files a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_256x256.png and /dev/null differ diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png deleted file mode 100644 index 80bb7aae847..00000000000 Binary files a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png and /dev/null differ diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_32x32.png deleted file mode 100644 index 5c65259f389..00000000000 Binary files a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_32x32.png and /dev/null differ diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png deleted file mode 100644 index 8e2ea66a4a1..00000000000 Binary files a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png and /dev/null differ diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_512x512.png deleted file mode 100644 index 80bb7aae847..00000000000 Binary files a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_512x512.png and /dev/null differ diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png deleted file mode 100644 index 099cbd00b66..00000000000 Binary files a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png and /dev/null differ diff --git a/apple-sso-extension/Assets.xcassets/Contents.json b/apple-sso-extension/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a7f..00000000000 --- a/apple-sso-extension/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apple-sso-extension/Fleet PSSO.xcodeproj/project.pbxproj b/apple-sso-extension/Fleet PSSO.xcodeproj/project.pbxproj deleted file mode 100644 index 1ef75ca05d8..00000000000 --- a/apple-sso-extension/Fleet PSSO.xcodeproj/project.pbxproj +++ /dev/null @@ -1,423 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 56; - objects = { - -/* Begin PBXBuildFile section */ - A1000001000000000000A001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B001 /* AppDelegate.swift */; }; - A1000001000000000000A002 /* AuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B002 /* AuthenticationViewController.swift */; }; - A1000001000000000000A003 /* AuthenticationViewController+PSSO.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B003 /* AuthenticationViewController+PSSO.swift */; }; - A1000001000000000000A004 /* AuthenticationViewController+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B004 /* AuthenticationViewController+Shared.swift */; }; - A1000001000000000000A005 /* FleetPSSOExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C1000001000000000000C002 /* FleetPSSOExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - A1000001000000000000A006 /* AuthenticationViewController+Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B009 /* AuthenticationViewController+Networking.swift */; }; - A1000001000000000000A007 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B010 /* Assets.xcassets */; }; - A1000001000000000000A008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B010 /* Assets.xcassets */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 61000001000000000000A001 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 51000001000000000000A001 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 11000001000000000000A002; - remoteInfo = FleetPSSOExtension; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - D1000001000000000000D001 /* Embed Foundation Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - A1000001000000000000A005 /* FleetPSSOExtension.appex in Embed Foundation Extensions */, - ); - name = "Embed Foundation Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - B1000001000000000000B001 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - B1000001000000000000B002 /* AuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewController.swift; sourceTree = ""; }; - B1000001000000000000B003 /* AuthenticationViewController+PSSO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthenticationViewController+PSSO.swift"; sourceTree = ""; }; - B1000001000000000000B004 /* AuthenticationViewController+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthenticationViewController+Shared.swift"; sourceTree = ""; }; - B1000001000000000000B005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - B1000001000000000000B006 /* FleetPSSO.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FleetPSSO.entitlements; sourceTree = ""; }; - B1000001000000000000B007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - B1000001000000000000B008 /* FleetPSSOExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FleetPSSOExtension.entitlements; sourceTree = ""; }; - B1000001000000000000B009 /* AuthenticationViewController+Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthenticationViewController+Networking.swift"; sourceTree = ""; }; - C1000001000000000000C001 /* FleetPSSO.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FleetPSSO.app; sourceTree = BUILT_PRODUCTS_DIR; }; - C1000001000000000000C002 /* FleetPSSOExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FleetPSSOExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - B1000001000000000000B010 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - E1000001000000000000E001 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - E1000001000000000000E002 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXResourcesBuildPhase section */ - 91000001000000000000A001 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A1000001000000000000A007 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 91000001000000000000A002 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A1000001000000000000A008 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXGroup section */ - F1000001000000000000F001 = { - isa = PBXGroup; - children = ( - F1000001000000000000F002 /* FleetPSSO */, - F1000001000000000000F003 /* FleetPSSOExtension */, - B1000001000000000000B010 /* Assets.xcassets */, - F1000001000000000000F004 /* Products */, - ); - sourceTree = ""; - }; - F1000001000000000000F002 /* FleetPSSO */ = { - isa = PBXGroup; - children = ( - B1000001000000000000B001 /* AppDelegate.swift */, - B1000001000000000000B005 /* Info.plist */, - B1000001000000000000B006 /* FleetPSSO.entitlements */, - ); - path = FleetPSSO; - sourceTree = ""; - }; - F1000001000000000000F003 /* FleetPSSOExtension */ = { - isa = PBXGroup; - children = ( - B1000001000000000000B002 /* AuthenticationViewController.swift */, - B1000001000000000000B003 /* AuthenticationViewController+PSSO.swift */, - B1000001000000000000B004 /* AuthenticationViewController+Shared.swift */, - B1000001000000000000B009 /* AuthenticationViewController+Networking.swift */, - B1000001000000000000B007 /* Info.plist */, - B1000001000000000000B008 /* FleetPSSOExtension.entitlements */, - ); - path = FleetPSSOExtension; - sourceTree = ""; - }; - F1000001000000000000F004 /* Products */ = { - isa = PBXGroup; - children = ( - C1000001000000000000C001 /* FleetPSSO.app */, - C1000001000000000000C002 /* FleetPSSOExtension.appex */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 11000001000000000000A001 /* FleetPSSO */ = { - isa = PBXNativeTarget; - buildConfigurationList = 21000001000000000000A001 /* Build configuration list for PBXNativeTarget "FleetPSSO" */; - buildPhases = ( - 31000001000000000000A001 /* Sources */, - E1000001000000000000E001 /* Frameworks */, - 91000001000000000000A001 /* Resources */, - D1000001000000000000D001 /* Embed Foundation Extensions */, - ); - buildRules = ( - ); - dependencies = ( - 41000001000000000000A001 /* PBXTargetDependency */, - ); - name = FleetPSSO; - productName = FleetPSSO; - productReference = C1000001000000000000C001 /* FleetPSSO.app */; - productType = "com.apple.product-type.application"; - }; - 11000001000000000000A002 /* FleetPSSOExtension */ = { - isa = PBXNativeTarget; - buildConfigurationList = 21000001000000000000A002 /* Build configuration list for PBXNativeTarget "FleetPSSOExtension" */; - buildPhases = ( - 31000001000000000000A002 /* Sources */, - E1000001000000000000E002 /* Frameworks */, - 91000001000000000000A002 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = FleetPSSOExtension; - productName = FleetPSSOExtension; - productReference = C1000001000000000000C002 /* FleetPSSOExtension.appex */; - productType = "com.apple.product-type.app-extension"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 51000001000000000000A001 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; - TargetAttributes = { - 11000001000000000000A001 = { - CreatedOnToolsVersion = 15.0; - }; - 11000001000000000000A002 = { - CreatedOnToolsVersion = 15.0; - }; - }; - }; - buildConfigurationList = 21000001000000000000A000 /* Build configuration list for PBXProject "Fleet PSSO" */; - compatibilityVersion = "Xcode 14.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = F1000001000000000000F001; - productRefGroup = F1000001000000000000F004 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 11000001000000000000A001 /* FleetPSSO */, - 11000001000000000000A002 /* FleetPSSOExtension */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXSourcesBuildPhase section */ - 31000001000000000000A001 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A1000001000000000000A001 /* AppDelegate.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 31000001000000000000A002 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A1000001000000000000A002 /* AuthenticationViewController.swift in Sources */, - A1000001000000000000A003 /* AuthenticationViewController+PSSO.swift in Sources */, - A1000001000000000000A004 /* AuthenticationViewController+Shared.swift in Sources */, - A1000001000000000000A006 /* AuthenticationViewController+Networking.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 41000001000000000000A001 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 11000001000000000000A002 /* FleetPSSOExtension */; - targetProxy = 61000001000000000000A001 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin XCBuildConfiguration section */ - 71000001000000000000A001 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; - ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 71000001000000000000A002 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; - ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - SDKROOT = macosx; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 71000001000000000000A003 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = FleetPSSO/FleetPSSO.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - CODE_SIGN_STYLE = Manual; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 5K28R5ZUK5; - ENABLE_APP_SANDBOX = YES; - ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; - ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; - ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; - ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; - ENABLE_RESOURCE_ACCESS_CALENDARS = NO; - ENABLE_RESOURCE_ACCESS_CAMERA = NO; - ENABLE_RESOURCE_ACCESS_CONTACTS = NO; - ENABLE_RESOURCE_ACCESS_LOCATION = NO; - ENABLE_RESOURCE_ACCESS_PRINTING = NO; - ENABLE_RESOURCE_ACCESS_USB = NO; - INFOPLIST_FILE = FleetPSSO/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fleetdm.pssotesting; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "PSSO Testing App"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 71000001000000000000A004 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = FleetPSSO/FleetPSSO.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - CODE_SIGN_STYLE = Manual; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 5K28R5ZUK5; - ENABLE_APP_SANDBOX = YES; - ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; - ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; - ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; - ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; - ENABLE_RESOURCE_ACCESS_CALENDARS = NO; - ENABLE_RESOURCE_ACCESS_CAMERA = NO; - ENABLE_RESOURCE_ACCESS_CONTACTS = NO; - ENABLE_RESOURCE_ACCESS_LOCATION = NO; - ENABLE_RESOURCE_ACCESS_PRINTING = NO; - ENABLE_RESOURCE_ACCESS_USB = NO; - INFOPLIST_FILE = FleetPSSO/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fleetdm.pssotesting; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "PSSO Testing App"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 71000001000000000000A005 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = FleetPSSOExtension/FleetPSSOExtension.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - CODE_SIGN_STYLE = Manual; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 5K28R5ZUK5; - INFOPLIST_FILE = FleetPSSOExtension/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@executable_path/../../../../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fleetdm.pssotesting.extension; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "PSSO Testing Extension"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 71000001000000000000A006 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = FleetPSSOExtension/FleetPSSOExtension.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - CODE_SIGN_STYLE = Manual; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 5K28R5ZUK5; - INFOPLIST_FILE = FleetPSSOExtension/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@executable_path/../../../../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fleetdm.pssotesting.extension; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "PSSO Testing Extension"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 21000001000000000000A000 /* Build configuration list for PBXProject "Fleet PSSO" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 71000001000000000000A001 /* Debug */, - 71000001000000000000A002 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 21000001000000000000A001 /* Build configuration list for PBXNativeTarget "FleetPSSO" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 71000001000000000000A003 /* Debug */, - 71000001000000000000A004 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 21000001000000000000A002 /* Build configuration list for PBXNativeTarget "FleetPSSOExtension" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 71000001000000000000A005 /* Debug */, - 71000001000000000000A006 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 51000001000000000000A001 /* Project object */; -} diff --git a/apple-sso-extension/FleetPSSO/AppDelegate.swift b/apple-sso-extension/FleetPSSO/AppDelegate.swift deleted file mode 100644 index 46203cae109..00000000000 --- a/apple-sso-extension/FleetPSSO/AppDelegate.swift +++ /dev/null @@ -1,27 +0,0 @@ -// AppDelegate.swift -// FleetPSSO host app -// -// Empty Cocoa shell whose only job is to be installable so macOS picks up -// the bundled FleetPSSOExtension. Launching the app once after install is -// enough; the user can quit immediately afterwards. - -import Cocoa - -@main -final class AppDelegate: NSObject, NSApplicationDelegate { - private var window: NSWindow? - - func applicationDidFinishLaunching(_ note: Notification) { - let rect = NSRect(x: 0, y: 0, width: 480, height: 240) - let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable] - window = NSWindow(contentRect: rect, styleMask: style, - backing: .buffered, defer: false) - window?.title = "Fleet PSSO" - window?.center() - window?.makeKeyAndOrderFront(nil) - } - - func applicationShouldTerminateAfterLastWindowClosed(_ s: NSApplication) -> Bool { - true - } -} diff --git a/apple-sso-extension/FleetPSSO/Info.plist b/apple-sso-extension/FleetPSSO/Info.plist deleted file mode 100644 index bf3bbf16599..00000000000 --- a/apple-sso-extension/FleetPSSO/Info.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 0.1.0 - CFBundleVersion - 1 - LSMinimumSystemVersion - 13.0 - NSPrincipalClass - NSApplication - - diff --git a/apple-sso-extension/README.md b/apple-sso-extension/README.md deleted file mode 100644 index 779df694676..00000000000 --- a/apple-sso-extension/README.md +++ /dev/null @@ -1,144 +0,0 @@ -# Fleet PSSO (Platform Single Sign-On) Extension — POC - -This is the macOS-side scaffolding for Fleet's Platform Single Sign-On -v2 + Password Mode proof of concept. The Fleet server provides the -IdP endpoints (nonce, JWKS, token, registration); this Xcode project -is what gets installed on a Mac, signed with a Developer ID, notarized, -and bound to Fleet via a `com.apple.extensiblesso` configuration profile. - -> Status: POC scaffolding. The framework conformances compile and the -> registration request flow is wired end-to-end, but the password -> sign-in path is intentionally stubbed. Production hardening (token -> caching, error UX, retries, telemetry) is out of scope here. - -## Layout - -``` -apple-sso-extension/ -├── Fleet PSSO.xcodeproj/ -├── FleetPSSO/ # Cocoa host app (empty shell) -│ ├── AppDelegate.swift -│ ├── Info.plist -│ └── FleetPSSO.entitlements -├── FleetPSSOExtension/ # The actual SSO extension -│ ├── AuthenticationViewController.swift -│ ├── AuthenticationViewController+PSSO.swift -│ ├── AuthenticationViewController+Shared.swift -│ ├── AuthenticationViewController+Networking.swift -│ ├── Info.plist -│ └── FleetPSSOExtension.entitlements -└── README.md -``` - -The host app exists solely so the extension bundle is installable — -launching it once is enough for macOS to discover the bundled -`.appex`. After that the host app does nothing. - -## How it fits with Fleet's server - -The Fleet server exposes the Platform SSO endpoints under -`/api/mdm/apple/psso/`: - -- `POST /api/mdm/apple/psso/nonce` — single-use nonces for token requests -- `POST /api/mdm/apple/psso/registration` — device key registration -- `POST /api/mdm/apple/psso/token` — password login / key request / key exchange -- `GET /api/mdm/apple/psso/jwks` — Fleet's PSSO signing public key - -The extension derives all of them from the single `BaseURL` value in -`loginManager.extensionData`, i.e. the arbitrary dictionary in the -extensible-SSO profile. The issuer/audience is the BaseURL's bare -hostname. - -## Configuration profile - -Install a `com.apple.extensiblesso` profile referencing the extension -bundle ID and Team ID, with an `ExtensionData` dict that includes only -the Fleet server URL (see `fleet-sso-extension-example.mobileconfig` -for a complete profile): - -```xml -BaseURL https://fleet.example.com -``` - -The hostname must also be served as an Apple App Site Association -file at: - -``` -https:///.well-known/apple-app-site-association -``` - -containing an `authsrv` entry that names the extension bundle's -`.`. - -## Placeholders to fill in - -| Placeholder | Where | -|-----------------------|------------------------------------------------| -| `fleet.example.com` | both `.entitlements` files; AASA hosting | -| `com.fleetdm.psso` | `Fleet PSSO.xcodeproj/project.pbxproj` | -| `com.fleetdm.psso.extension` | same | -| Development Team ID | Xcode → target → Signing & Capabilities | - -## Build / sign / package / notarize - -`build.sh` runs the whole pipeline end-to-end: - -```bash -# Apple Developer credentials for notarytool. AC_PASSWORD must be an -# app-specific password — use @keychain: if you've stored one. -export AC_USERNAME="you@example.com" -export AC_TEAM_ID="TEAMID" -export AC_PASSWORD="@keychain:notary" - -./build.sh -``` - -This produces `./FleetPSSO.pkg`, a Developer ID-signed and notarized -installer. The pkg drops `FleetPSSO.app` into `/Applications` (which also -registers the bundled `.appex` with the system). - -Install it: - -```bash -sudo installer -pkg FleetPSSO.pkg -target / -``` - -Behind the scenes, the script: - -1. Builds the app unsigned with `xcodebuild`. -2. Signs the `.appex` and `.app` with **Developer ID Application** (the - hardened runtime + secure timestamp options that notarization - requires). -3. Wraps the `.app` in a flat installer with `pkgbuild`, signs it with - **Developer ID Installer**, and sets the install location to - `/Applications`. -4. Submits the pkg to `notarytool` and waits for the verdict. -5. Staples the notarization ticket to the pkg so it installs offline. - -You'll need both certificates in your login keychain: -- *Developer ID Application: Your Name (TEAMID)* -- *Developer ID Installer: Your Name (TEAMID)* - -## Out of scope (intentional) - -- Real password sign-in UI / token caching -- Keychain persistence (the framework owns key material via - `ASAuthorizationProviderExtensionLoginManager`) -- Refresh, revocation, multi-account -- Pretty error / progress UX - -## Open Apple API questions for the implementer - -- `ASAuthorizationProviderExtensionLoginManager.userDeviceKey(forKeyType:)` - was the throwing variant on macOS 14; double-check the signature on - the macOS SDK you build against — Apple has both throwing and - completion-handler variants depending on release. -- `ASAuthorizationProviderExtensionLoginConfiguration.supportedGrantTypes` - on the password path: confirm whether `.password` is the correct - case name on your SDK. -- AASA `authsrv:` entry format vs. `webcredentials:` — for PSSO it is - `authsrv:` per WWDC 2022. - -## Debug notes - -authsrv: links and swcutil/swcd were the biggest stumbling block I ran into diff --git a/apple-sso-extension/build.sh b/apple-sso-extension/build.sh deleted file mode 100755 index ab593a8d31d..00000000000 --- a/apple-sso-extension/build.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash -# Build, sign, package, and notarize Fleet PSSO. -# -# Output: ./FleetPSSO.pkg — a Developer ID-signed, notarized installer that -# drops FleetPSSO.app into /Applications and brings the bundled SSO appex -# along for the ride. -# -# Required env for notarization: -# AC_USERNAME Apple ID email -# AC_TEAM_ID Apple Developer Team ID -# AC_PASSWORD App-specific password (use @keychain:... for keychain refs) - -set -euo pipefail - -# Not checked in -APP_PROFILE="./profiles/PSSO_Testing_App.provisionprofile" -APPEX_PROFILE="./profiles/PSSO_Testing_Extension.provisionprofile" - -# TODO Update -TEAM_ID='5K28R5ZUK5' -NAME_WITH_TEAM="Elijah Montgomery (${TEAM_ID})" -APP_SIGN_ID="Developer ID Application: ${NAME_WITH_TEAM}" -PKG_SIGN_ID="Developer ID Installer: ${NAME_WITH_TEAM}" - -APP_PATH="./build/Build/Products/Release/FleetPSSO.app" -APPEX_PATH="${APP_PATH}/Contents/PlugIns/FleetPSSOExtension.appex" -PKG_PATH="./FleetPSSO.pkg" - -# Developer ID provisioning profiles. Required to make codesign honor the -# `com.apple.developer.*` entitlements (associated-domains, etc.). Download -# these from the Apple Developer portal under Profiles → Developer ID for -# each App ID and place them at the paths below. - -for p in "${APP_PROFILE}" "${APPEX_PROFILE}"; do - if [[ ! -f "${p}" ]]; then - echo "ERROR: missing provisioning profile ${p}" >&2 - echo " Download Developer ID profiles for the host app and extension" >&2 - echo " from https://developer.apple.com/account/resources/profiles/list" >&2 - exit 1 - fi -done - -echo "==> Clean build (unsigned; we re-sign with Developer ID below)" -xcodebuild -project "Fleet PSSO.xcodeproj" -scheme FleetPSSO -configuration Release -derivedDataPath ./build \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - clean build - -echo "==> Embedding extension provisioning profile" -cp "${APPEX_PROFILE}" "${APPEX_PATH}/Contents/embedded.provisionprofile" - -echo "==> Signing SSO extension" -codesign --force --options runtime --timestamp \ - --sign "${APP_SIGN_ID}" \ - --entitlements FleetPSSOExtension/FleetPSSOExtension.entitlements \ - "${APPEX_PATH}" - -echo "==> Embedding host app provisioning profile" -cp "${APP_PROFILE}" "${APP_PATH}/Contents/embedded.provisionprofile" - -echo "==> Signing host app" -codesign --force --options runtime --timestamp \ - --sign "${APP_SIGN_ID}" \ - --entitlements FleetPSSO/FleetPSSO.entitlements \ - "${APP_PATH}" - -echo "==> Building installer pkg (installs to /Applications)" -pkgbuild \ - --component "${APP_PATH}" \ - --install-location /Applications \ - --sign "${PKG_SIGN_ID}" \ - --timestamp \ - "${PKG_PATH}" - -echo "==> Notarizing pkg" -xcrun notarytool submit "${PKG_PATH}" \ - --apple-id "${AC_USERNAME}" \ - --team-id "${AC_TEAM_ID}" \ - --password "${AC_PASSWORD}" \ - --wait - -echo "==> Stapling notarization ticket to pkg" -xcrun stapler staple "${PKG_PATH}" - -echo "" -echo "Done. Install with:" -echo " sudo installer -pkg ${PKG_PATH} -target /" diff --git a/apple-sso-extension/FleetPSSOExtension/FleetPSSOExtension.entitlements b/apps/fleet-desktop-macos/FleetDesktop/FleetDesktop.entitlements similarity index 50% rename from apple-sso-extension/FleetPSSOExtension/FleetPSSOExtension.entitlements rename to apps/fleet-desktop-macos/FleetDesktop/FleetDesktop.entitlements index 3d7c28f5647..34f494f1bad 100644 --- a/apple-sso-extension/FleetPSSOExtension/FleetPSSOExtension.entitlements +++ b/apps/fleet-desktop-macos/FleetDesktop/FleetDesktop.entitlements @@ -3,18 +3,12 @@ com.apple.application-identifier - 5K28R5ZUK5.com.fleetdm.pssotesting.extension + 8VBZ3948LU.com.fleetdm.fleet-desktop com.apple.developer.team-identifier - 5K28R5ZUK5 + 8VBZ3948LU com.apple.developer.associated-domains - - authsrv:jordan-fleetdm.ngrok.app - - com.apple.security.app-sandbox - - com.apple.security.network.client - - com.apple.security.network.server + + com.apple.developer.associated-domains.mdm-managed diff --git a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Networking.swift b/apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+Networking.swift similarity index 100% rename from apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Networking.swift rename to apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+Networking.swift diff --git a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+PSSO.swift b/apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+PSSO.swift similarity index 100% rename from apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+PSSO.swift rename to apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+PSSO.swift diff --git a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Shared.swift b/apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+Shared.swift similarity index 100% rename from apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Shared.swift rename to apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+Shared.swift diff --git a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController.swift b/apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController.swift similarity index 100% rename from apple-sso-extension/FleetPSSOExtension/AuthenticationViewController.swift rename to apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController.swift diff --git a/apple-sso-extension/FleetPSSO/FleetPSSO.entitlements b/apps/fleet-desktop-macos/FleetPSSOExtension/FleetPSSOExtension.entitlements similarity index 70% rename from apple-sso-extension/FleetPSSO/FleetPSSO.entitlements rename to apps/fleet-desktop-macos/FleetPSSOExtension/FleetPSSOExtension.entitlements index c72741ff64e..9cbac326393 100644 --- a/apple-sso-extension/FleetPSSO/FleetPSSO.entitlements +++ b/apps/fleet-desktop-macos/FleetPSSOExtension/FleetPSSOExtension.entitlements @@ -3,13 +3,13 @@ com.apple.application-identifier - 5K28R5ZUK5.com.fleetdm.pssotesting + 8VBZ3948LU.com.fleetdm.fleet-desktop.pssoextension com.apple.developer.team-identifier - 5K28R5ZUK5 + 8VBZ3948LU com.apple.developer.associated-domains - - authsrv:jordan-fleetdm.ngrok.app - + + com.apple.developer.associated-domains.mdm-managed + com.apple.security.app-sandbox com.apple.security.network.client diff --git a/apple-sso-extension/FleetPSSOExtension/Info.plist b/apps/fleet-desktop-macos/FleetPSSOExtension/Info.plist similarity index 57% rename from apple-sso-extension/FleetPSSOExtension/Info.plist rename to apps/fleet-desktop-macos/FleetPSSOExtension/Info.plist index c0274760a3c..cfa279f000e 100644 --- a/apple-sso-extension/FleetPSSOExtension/Info.plist +++ b/apps/fleet-desktop-macos/FleetPSSOExtension/Info.plist @@ -3,21 +3,21 @@ CFBundleDevelopmentRegionen - CFBundleDisplayNameFleetPSSOExtension - CFBundleExecutable$(EXECUTABLE_NAME) - CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleDisplayNameFleet PSSO Extension + CFBundleExecutableFleetPSSOExtension + CFBundleIdentifiercom.fleetdm.fleet-desktop.pssoextension CFBundleInfoDictionaryVersion6.0 - CFBundleName$(PRODUCT_NAME) + CFBundleNameFleetPSSOExtension CFBundlePackageTypeXPC! - CFBundleShortVersionString0.1.0 - CFBundleVersion1 - LSMinimumSystemVersion13.0 + CFBundleShortVersionString1.3.1 + CFBundleVersion6 + LSMinimumSystemVersion14.0 NSExtension NSExtensionPointIdentifier com.apple.AppSSO.idp-extension NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).AuthenticationViewController + FleetPSSOExtension.AuthenticationViewController NSExtensionAttributes ASAuthorizationProviderExtensionAuthorizationProtocolVersion diff --git a/apps/fleet-desktop-macos/README.md b/apps/fleet-desktop-macos/README.md index 54deceb2215..97a6e872dce 100644 --- a/apps/fleet-desktop-macos/README.md +++ b/apps/fleet-desktop-macos/README.md @@ -2,6 +2,8 @@ A native macOS application that provides end users with a self-service portal for [Fleet](https://fleetdm.com). It integrates with Fleet's [orbit](https://fleetdm.com/docs/get-started/anatomy#orbit) agent to give users direct access to device management features in a native window instead of a browser. +It also embeds the **Fleet Platform SSO (PSSO) extension** (`FleetPSSOExtension.appex`), which implements Apple's Platform Single Sign-On v2 + Password Mode so Fleet can create a Mac's local account and keep its password in sync with the user's IdP credentials. See [Platform SSO extension](#platform-sso-extension) below. + > **Heads up — two things named "Fleet Desktop":** Fleet's agent already ships a tray/menu-bar component called Fleet Desktop (bundle ID `com.fleetdm.desktop`, built from `orbit/cmd/desktop`). This is a separate, standalone native app (bundle ID `com.fleetdm.fleet-desktop`) distributed as its own `.pkg`. They use different bundle IDs and can coexist. ## Features @@ -9,6 +11,7 @@ A native macOS application that provides end users with a self-service portal fo - **Native macOS app** built with Swift and AppKit - **Universal binary** supporting Apple Silicon (arm64) and Intel (x86_64) - **Self-service portal** embedded in a native window via WKWebView +- **Embedded Platform SSO extension** for IdP-based local account creation and password sync - **Automatic token refresh** handles hourly token rotation transparently - **Loading screen** with Fleet logo while the portal loads - **File download support** for `.mobileconfig` profiles and other files served by Fleet @@ -19,7 +22,7 @@ A native macOS application that provides end users with a self-service portal fo ## Requirements -- macOS 13.0 (Ventura) or later +- macOS 13.0 (Ventura) or later for the app; the PSSO extension requires macOS 14.0+ (the password-sync feature targets macOS 26+) - MDM-enabled Mac with Fleet's managed preferences profile installed - Fleet's orbit agent installed and enrolled - The orbit identifier file must exist at `/opt/orbit/identifier` @@ -33,6 +36,8 @@ The signed, notarized `.pkg` is produced by CI (see [CI/CD](#cicd)) and uploaded The installer requires an MDM-enabled Mac. It checks for the Fleet managed preferences profile before proceeding — if the profile is not found, the installer displays an error and aborts. The app is placed in `/Applications` with `root:admin` ownership and `755` permissions. On upgrades, the installer gracefully quits Fleet Desktop before installing and automatically relaunches it afterward. +Installing the app into `/Applications` is also what registers the bundled `FleetPSSOExtension.appex` with the system so it becomes selectable by a `com.apple.extensiblesso` configuration profile. + ## How It Works 1. **Reads the Fleet URL** from MDM managed preferences (see [Configuration Sources](#configuration-sources)) @@ -63,6 +68,47 @@ When Fleet serves downloadable content (e.g., MDM enrollment profiles): - The WebView uses a non-persistent data store (no cookies or cache persist between sessions) - Mutable state is protected by a serial dispatch queue for thread safety +## Platform SSO extension + +`FleetPSSOExtension.appex` is an Apple `com.apple.AppSSO.idp-extension` that implements Platform SSO v2 in Password Mode. The Fleet server provides the IdP endpoints; the extension registers the device's keys with Fleet and proxies password sign-in / key exchange through it. + +The extension is bundled inside the app at `Fleet Desktop.app/Contents/PlugIns/FleetPSSOExtension.appex` and ships in the same `.pkg`. + +### How it binds to a Fleet server + +The extension derives all of its endpoints from a single `BaseURL` value supplied in the `ExtensionData` dictionary of a `com.apple.extensiblesso` configuration profile (see [`fleet-sso-extension-example.mobileconfig`](./fleet-sso-extension-example.mobileconfig)): + +```xml +BaseURL https://fleet.example.com +``` + +From that it derives, under `/api/mdm/apple/psso/`: + +- `POST /nonce` — single-use nonces for token requests +- `POST /registration` — device key registration +- `POST /token` — password login / key request / key exchange +- `GET /jwks` — Fleet's PSSO signing public key + +The Fleet server also serves an Apple App Site Association file at `https:///.well-known/apple-app-site-association` containing an `authsrv` entry naming the extension's `.` — i.e. `8VBZ3948LU.com.fleetdm.fleet-desktop.pssoextension`. + +Because the same generic, CI-built extension must work against *any* Fleet server, the associated domain is **not** baked into the binary. Instead: + +- The entitlement `com.apple.developer.associated-domains` ships as an **empty array**, with `com.apple.developer.associated-domains.mdm-managed` set to `true`. +- The actual `authsrv:` domain (the configured Fleet server) is delivered at runtime by an MDM **AssociatedDomains** payload targeting the extension's bundle ID. + +### Entitlements + +Both the host app and the extension carry restricted (Apple-managed) entitlements. These are not freely assertable — `codesign` only honors them when a Developer ID **provisioning profile** that grants them is embedded in the bundle (see [Signing secrets](#signing-secrets)). + +| Bundle | Entitlement | Value | +|--------|-------------|-------| +| App + extension | `com.apple.developer.associated-domains` | empty array (must exist) | +| App + extension | `com.apple.developer.associated-domains.mdm-managed` | `true` | +| Extension only | `com.apple.security.app-sandbox` | `true` | +| Extension only | `com.apple.security.network.client` | `true` | + +The host app is deliberately **not** sandboxed — it reads `/opt/orbit/identifier` and the managed-preferences plist outside any container. App extensions are always sandboxed. + ## Development ### Project Structure @@ -70,22 +116,33 @@ When Fleet serves downloadable content (e.g., MDM enrollment profiles): ``` apps/fleet-desktop-macos/ ├── FleetDesktop/ -│ ├── FleetDesktopApp.swift # App delegate, main menu, entry point -│ ├── FleetService.swift # Config reading, token management, refresh timer -│ ├── BrowserWindow.swift # WKWebView window, loading overlay, downloads -│ ├── Info.plist # App bundle metadata -│ ├── AppIcon.icns # App icon -│ └── fleet-logo.png # Fleet logo for loading screen -├── build.sh # Compiles universal binary -└── build-pkg.sh # Creates the .pkg installer +│ ├── FleetDesktopApp.swift # App delegate, main menu, entry point +│ ├── FleetService.swift # Config reading, token management, refresh timer +│ ├── BrowserWindow.swift # WKWebView window, loading overlay, downloads +│ ├── Info.plist # App bundle metadata +│ ├── FleetDesktop.entitlements # Host-app entitlements (managed associated domains) +│ ├── AppIcon.icns # App icon +│ └── fleet-logo.png # Fleet logo for loading screen +├── FleetPSSOExtension/ +│ ├── AuthenticationViewController.swift # Principal class (SSO request handler) +│ ├── AuthenticationViewController+PSSO.swift # Registration handler +│ ├── AuthenticationViewController+Shared.swift # Payload / key-ID / config helpers +│ ├── AuthenticationViewController+Networking.swift # URLSession against Fleet +│ ├── Info.plist # appex metadata (NSExtension dict) +│ └── FleetPSSOExtension.entitlements # Extension entitlements +├── fleet-sso-extension-example.mobileconfig # Example com.apple.extensiblesso profile +├── build.sh # Compiles the universal app + appex +└── build-pkg.sh # Creates the .pkg installer ``` The CI workflow lives at [`.github/workflows/fleet-desktop-macos-build.yml`](../../.github/workflows/fleet-desktop-macos-build.yml). +The PSSO extension is built as a Foundation app extension with `swiftc`: there is no `main()`; the entry point is `NSExtensionMain` and the principal class is loaded from the appex `Info.plist`. `swiftc`'s `-module-name` must match the module prefix in `NSExtensionPrincipalClass` (`FleetPSSOExtension`). + ### Building Locally ```bash -# Build the app +# Build the app (with the embedded extension) ./build.sh # Run @@ -95,7 +152,16 @@ open "build/Fleet Desktop.app" ./build-pkg.sh ``` -Local builds are unsigned. Signing and notarization happen in CI with Fleet's Developer ID certificates. +Local builds are unsigned. Signing and notarization happen in CI with Fleet's Developer ID certificates and provisioning profiles. You can ad-hoc sign locally to sanity-check the bundle layout (the restricted entitlements won't be honored without a real profile): + +```bash +codesign --force --options runtime --sign - \ + --entitlements FleetPSSOExtension/FleetPSSOExtension.entitlements \ + "build/Fleet Desktop.app/Contents/PlugIns/FleetPSSOExtension.appex" +codesign --force --options runtime --sign - \ + --entitlements FleetDesktop/FleetDesktop.entitlements "build/Fleet Desktop.app" +codesign --verify --deep --strict "build/Fleet Desktop.app" +``` ### Environment Variables @@ -136,18 +202,19 @@ open fleet://refetch [`.github/workflows/fleet-desktop-macos-build.yml`](../../.github/workflows/fleet-desktop-macos-build.yml) runs on pull requests touching `apps/fleet-desktop-macos/**`, on push to `main`, and via manual dispatch. It: -1. Compiles a universal binary (arm64 + x86_64) -2. Code signs the app with Fleet's Developer ID Application certificate -3. Packages into a `.pkg` installer with a custom distribution XML -4. Signs the `.pkg` with Fleet's Developer ID Installer certificate -5. Notarizes with Apple and staples the ticket -6. Uploads the signed `.pkg` as a workflow artifact (retained for 30 days) +1. Compiles a universal binary (arm64 + x86_64) for the app and the extension, and assembles the `.appex` inside the `.app` +2. Embeds the Developer ID provisioning profiles into the app and extension bundles +3. Code signs **inside-out** — the extension first, then the host app — each with its own entitlements +4. Packages into a `.pkg` installer with a custom distribution XML +5. Signs the `.pkg` with Fleet's Developer ID Installer certificate +6. Notarizes with Apple and staples the ticket +7. Uploads the signed `.pkg` as a workflow artifact (retained for 30 days) -Pull requests (including from forks) only run step 1 — they verify the app compiles and packages, but skip signing/notarization, which require secrets unavailable to forks. +The workflow always signs and notarizes. Runs without access to the signing secrets — fork pull requests, or any run before the provisioning profiles have been added — **fail** rather than producing an unsigned artifact. ### Signing secrets -The workflow reuses the same repository secrets already used by Fleet's other macOS build workflows — **no new secrets are required**: +The workflow reuses the Developer ID certificate secrets already used by Fleet's other macOS build workflows, plus **two new provisioning-profile secrets** required for the extension's restricted entitlements: | Secret | Purpose | |--------|---------| @@ -156,9 +223,30 @@ The workflow reuses the same repository secrets already used by Fleet's other ma | `APPLE_USERNAME` / `APPLE_PASSWORD` | Apple ID + app-specific password for notarization | | `APPLE_TEAM_ID` | Apple Developer Team ID | | `KEYCHAIN_PASSWORD` | Temporary CI keychain password | +| `APPLE_FLEET_DESKTOP_APP_PROFILE_B64` | base64 of the Developer ID provisioning profile for `com.fleetdm.fleet-desktop` | +| `APPLE_PSSO_EXT_PROFILE_B64` | base64 of the Developer ID provisioning profile for `com.fleetdm.fleet-desktop.pssoextension` | The Developer ID certificate identities (SHA-1) are pinned in the workflow `env` block, matching the identities used by Fleet's orbit and fleetd-base builds. +#### Provisioning profiles (one-time Apple Developer portal setup) + +The `com.apple.developer.associated-domains*` entitlements are Apple-managed: `codesign` will not honor them without a Developer ID provisioning profile that grants them. Profiles are **not committed** — they are team/cert-bound build inputs that expire, so they're stored as the base64 secrets above (the same pattern as the `.p12` certs). + +Under Fleet's Apple Developer team (`8VBZ3948LU`, the team that owns the pinned Developer ID certificates): + +1. Register two App IDs: + - `com.fleetdm.fleet-desktop` (host app) + - `com.fleetdm.fleet-desktop.pssoextension` (extension) +2. Enable the **Associated Domains** and **MDM Managed Associated Domains** capabilities on both App IDs. +3. Create a **Developer ID** provisioning profile (distribution, platform macOS) for each App ID. **Select the same Developer ID Application certificate that CI signs with** — SHA-1 `604D877399AAEB7630A78B84F288E2D28A2EDE42` (the identity pinned in the workflow). Fleet has more than one "Developer ID Application: Fleet Device Management Inc" certificate; a profile generated against the wrong one will sign and **notarize successfully but get SIGKILLed by AMFI at launch**, because AMFI requires the signing cert to appear in the profile's `DeveloperCertificates`. The `Verify profiles authorize the signing certificate` workflow step guards against this. +4. base64-encode each downloaded `.provisionprofile` and store them as `APPLE_FLEET_DESKTOP_APP_PROFILE_B64` and `APPLE_PSSO_EXT_PROFILE_B64`: + ```bash + base64 -i FleetDesktop_DeveloperID.provisionprofile | pbcopy # → APPLE_FLEET_DESKTOP_APP_PROFILE_B64 + base64 -i FleetPSSOExtension_DeveloperID.provisionprofile | pbcopy # → APPLE_PSSO_EXT_PROFILE_B64 + ``` + +Re-encode and update the secrets when a profile expires or the signing certificate is rotated. To inspect a profile — its entitlements and, crucially, the certs it authorizes — dump it with `security cms -D -i .provisionprofile`; the `DeveloperCertificates` array must contain the CI signing cert above. + ## License Licensed under the MIT Expat license via the repository [root LICENSE](../LICENSE). diff --git a/apps/fleet-desktop-macos/build.sh b/apps/fleet-desktop-macos/build.sh index b42c9c52740..8b942ad9988 100755 --- a/apps/fleet-desktop-macos/build.sh +++ b/apps/fleet-desktop-macos/build.sh @@ -3,33 +3,35 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SRC_DIR="$SCRIPT_DIR/FleetDesktop" +EXT_SRC_DIR="$SCRIPT_DIR/FleetPSSOExtension" BUILD_DIR="$SCRIPT_DIR/build" APP_DIR="$BUILD_DIR/Fleet Desktop.app" CONTENTS_DIR="$APP_DIR/Contents" MACOS_DIR="$CONTENTS_DIR/MacOS" +APPEX_DIR="$CONTENTS_DIR/PlugIns/FleetPSSOExtension.appex" +APPEX_CONTENTS_DIR="$APPEX_DIR/Contents" +APPEX_MACOS_DIR="$APPEX_CONTENTS_DIR/MacOS" echo "Building Fleet Desktop..." rm -rf "$BUILD_DIR" mkdir -p "$MACOS_DIR" +SDK="$(xcrun --show-sdk-path)" + +# --- Host app ------------------------------------------------------------- SOURCES=( "$SRC_DIR/FleetService.swift" "$SRC_DIR/BrowserWindow.swift" "$SRC_DIR/FleetDesktopApp.swift" ) -SDK="$(xcrun --show-sdk-path)" SWIFT_FLAGS=(-sdk "$SDK" -parse-as-library -O) -# Build for arm64 swiftc -target arm64-apple-macos13 "${SWIFT_FLAGS[@]}" \ -o "$BUILD_DIR/FleetDesktop-arm64" "${SOURCES[@]}" - -# Build for x86_64 swiftc -target x86_64-apple-macos13 "${SWIFT_FLAGS[@]}" \ -o "$BUILD_DIR/FleetDesktop-x86_64" "${SOURCES[@]}" -# Create universal binary lipo -create \ "$BUILD_DIR/FleetDesktop-arm64" \ "$BUILD_DIR/FleetDesktop-x86_64" \ @@ -47,5 +49,46 @@ if [ -f "$SRC_DIR/fleet-logo.png" ]; then cp "$SRC_DIR/fleet-logo.png" "$CONTENTS_DIR/Resources/fleet-logo.png" fi +# --- Platform SSO extension (.appex) -------------------------------------- +# Built as a Foundation app extension: no main(), entry point is +# NSExtensionMain (the principal class comes from the appex Info.plist). +# -module-name must match the NSExtensionPrincipalClass module prefix. +echo "Building Fleet PSSO extension..." +mkdir -p "$APPEX_MACOS_DIR" + +EXT_SOURCES=( + "$EXT_SRC_DIR/AuthenticationViewController.swift" + "$EXT_SRC_DIR/AuthenticationViewController+PSSO.swift" + "$EXT_SRC_DIR/AuthenticationViewController+Shared.swift" + "$EXT_SRC_DIR/AuthenticationViewController+Networking.swift" +) +EXT_SWIFT_FLAGS=( + -sdk "$SDK" -parse-as-library -O + -module-name FleetPSSOExtension + -framework AuthenticationServices -framework IOKit + -Xlinker -e -Xlinker _NSExtensionMain +) + +swiftc -target arm64-apple-macos14 "${EXT_SWIFT_FLAGS[@]}" \ + -o "$BUILD_DIR/FleetPSSOExtension-arm64" "${EXT_SOURCES[@]}" +swiftc -target x86_64-apple-macos14 "${EXT_SWIFT_FLAGS[@]}" \ + -o "$BUILD_DIR/FleetPSSOExtension-x86_64" "${EXT_SOURCES[@]}" + +lipo -create \ + "$BUILD_DIR/FleetPSSOExtension-arm64" \ + "$BUILD_DIR/FleetPSSOExtension-x86_64" \ + -output "$APPEX_MACOS_DIR/FleetPSSOExtension" + +rm "$BUILD_DIR/FleetPSSOExtension-arm64" "$BUILD_DIR/FleetPSSOExtension-x86_64" + +cp "$EXT_SRC_DIR/Info.plist" "$APPEX_CONTENTS_DIR/Info.plist" + +# Keep the embedded extension's version in lockstep with the host app. +APP_SHORT_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$CONTENTS_DIR/Info.plist") +APP_BUILD_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$CONTENTS_DIR/Info.plist") +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $APP_SHORT_VERSION" "$APPEX_CONTENTS_DIR/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $APP_BUILD_VERSION" "$APPEX_CONTENTS_DIR/Info.plist" + echo "Build complete: $APP_DIR" +echo " embedded extension: $APPEX_DIR" echo "Run with: open \"$APP_DIR\"" diff --git a/apple-sso-extension/fleet-sso-extension-example.mobileconfig b/apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig similarity index 63% rename from apple-sso-extension/fleet-sso-extension-example.mobileconfig rename to apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig index 4ae19258846..d2106d089e2 100644 --- a/apple-sso-extension/fleet-sso-extension-example.mobileconfig +++ b/apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig @@ -11,7 +11,7 @@ https://fleet.example.com ExtensionIdentifier - com.fleetdm.pssotesting.extension + com.fleetdm.fleet-desktop.pssoextension PayloadDisplayName Fleet Extensible Single Sign-On PayloadIdentifier @@ -34,7 +34,7 @@ ScreenLockedBehavior DoNotHandle TeamIdentifier - 5K28R5ZUK5 + 8VBZ3948LU Type Redirect URLs @@ -42,6 +42,36 @@ https://fleet.example.com + + PayloadType + com.apple.associated-domains + PayloadIdentifier + com.apple.associated-domains.4D68D4CF-1250-4FF4-AFFB-1176DB539C49 + PayloadUUID + d77b6a04-6527-4333-1010-46422e8a5844 + PayloadVersion + 1 + Configuration + + + ApplicationIdentifier + 8VBZ3948LU.com.fleetdm.fleet-desktop + AssociatedDomains + + authsrv:fleet.example.com + + + + ApplicationIdentifier + 8VBZ3948LU.com.fleetdm.fleet-desktop.pssoextension + AssociatedDomains + + authsrv:fleet.example.com + + + + + PayloadDisplayName Fleet Platform SSO diff --git a/docs/Contributing/research/mdm/psso.md b/docs/Contributing/research/mdm/psso.md index 72c9acfcb48..82ec23c8df6 100644 --- a/docs/Contributing/research/mdm/psso.md +++ b/docs/Contributing/research/mdm/psso.md @@ -113,13 +113,13 @@ The nonce store mirrors `server/mdm/acme/internal/redis_nonces_store/` and expos - `mdm_apple_psso_devices` — primary key `host_id`, stores the device's signing and encryption public keys (PEM), the negotiated KeyExchangeKey, and registration/update timestamps. - `mdm_apple_psso_key_ids` — primary key `kid`, foreign key `host_id`, plus `key_type` and `pem`. The extension references keys by SHA-256 hash of the public key, so the server needs an index keyed by that hash to resolve incoming requests back to a device. -### JWKS signing key bootstrap timing (OPEN) +### JWKS signing key bootstrap -Current placeholder behavior: the JWKS signing key is lazily minted on the first `GET /api/mdm/apple/psso/jwks` and persisted (encrypted) in `mdm_config_assets` under `MDMAssetPSSOSigningKey`. Alternatives under consideration include minting on the first time a user enables the feature. The lazy-mint code carries a `TODO`; decision pending. +The signing key(`MDMAssetPSSOSigningKey`) and the self-signed PSSO CA (`MDMAssetPSSOCACert`, backed by the same private key) are created once, the first time the feature is configured, via `bootstrapPSSOAssets` in `ModifyAppConfig` (covering the config API and GitOps). The bootstrap is idempotent and never regenerates existing assets, so the JWKS key and CA stay stable across reconfiguration and disable/re-enable. -### In-tree Swift extension at `apple-sso-extension/` +### In-tree Swift extension (shipped with Fleet Desktop) -The Swift sources for the SSO extension live in this repo at `apple-sso-extension/`. Signing and notarization happen out-of-band using the deployer's own Apple Developer ID; Fleet does not ship a signed binary. The hostname declared in the extension's `authsrv:` entitlement must match the hostname served by `/.well-known/apple-app-site-association`. +The Swift sources for the SSO extension live in this repo at `apps/fleet-desktop-macos/FleetPSSOExtension/`. The extension is built as an `.appex` embedded in the Fleet Desktop app and shipped in the same `.pkg`; CI signs and notarizes it with Fleet's Developer ID certificates and Developer ID provisioning profiles (team `8VBZ3948LU`). The associated domain is **not** baked into the binary — the entitlement ships as an empty array with `com.apple.developer.associated-domains.mdm-managed` set, and the actual `authsrv:` hostname is delivered at runtime by an MDM AssociatedDomains payload. That hostname must match the host served at `/.well-known/apple-app-site-association`. **Device registration must POST directly (no WKWebView).** `beginDeviceRegistration` submits the device's signing/encryption public keys to `/api/mdm/apple/psso/registration` via a direct `URLSession` POST, and reports `.success` only after Fleet returns 2xx. An earlier implementation routed the POST through a WKWebView navigation-delegate intercept (a holdover from an OAuth-code registration model). That web view isn't functional during Setup Assistant, so with `EnableRegistrationDuringSetup` the POST silently never fired, yet `completion(.success)` was still called unconditionally — the framework then went straight to nonce → token with an unregistered key and the token endpoint 404'd ("PSSOKeyID … not found", surfaced on-device as "Incorrect username or password"). Password-mode registration has no browser step, so the web view was never needed; awaiting the direct POST also guarantees the keys are persisted before the framework proceeds to authentication. @@ -210,42 +210,10 @@ A security review of the POC (covering the implementation and these productioniz The crypto was otherwise found sound: no passwords/refresh-tokens/client-secrets in logs or errors; SQL fully parameterized; JWE GCM nonces random with the protected header as AAD; `canonicalizeKID` consistent across store and lookup (no key aliasing); attacker-supplied `other_publickey` is curve-validated via `crypto/ecdh`; clean-room provenance intact (JOSE primitives only, no third-party PSSO SDK). -### CRITICAL [deploy] — IdP client secret disclosed via the config API - -`AppConfig.Obfuscate()` (`server/fleet/app.go`) masks SMTP/Jira/Zendesk/etc. secrets but has no case for `PSSOSettings`, so `GET /api/v1/fleet/config` returns `psso_settings.idp_client_secret` in cleartext to any caller with config read. A low-privilege user could lift the upstream IdP OAuth client credentials and use them directly against the customer's tenant. This is a live disclosure on the existing endpoint, not the cosmetic "mask in the UI" task framed under *Admin configuration* above. Fix: add a `PSSOSettings` case to `Obfuscate()`, and mirror the SMTP "keep existing secret when the client submits the mask" logic on the config write path so a PATCH echoing `********` doesn't clobber the stored secret. - -### HIGH [deploy] — `PSSOSettings.Enabled` is never enforced - -No PSSO service method consults `cfg.PSSOSettings.Enabled`; the unauthenticated `/mdm/apple/psso/*` surface is live on every licensed instance even when an admin never enabled (or explicitly disabled) PSSO. Gate the device-facing methods (`PSSONonce`, `PSSORegisterComplete`, `PSSOToken`, and arguably JWKS/AASA) on `Enabled` in the service layer so all entry points are covered. - -**Addressed in #46942.** Every PSSO service method now checks the live `AppConfig.PSSOSettings` per request (`pssoSettingsIfConfigured`): nonce/registration/token return 400 and JWKS/AASA return 404 when the feature is disabled or incompletely configured. - -### HIGH [deploy] — Unbounded replay of token requests - -The verified JWT parse validates `exp`/`nbf` only if present, and inbound request JWTs are not required to carry an `exp` (nor is one enforced). The sole anti-replay control, `request_nonce`, is consumed best-effort — a miss is logged, not rejected (`ee/server/service/apple_psso.go`, `handlePSSOPasswordLogin`). A captured login-request JWS can therefore be replayed indefinitely to re-trigger IdP password validation and yield a fresh valid login-response JWE. This supersedes the milder "best-effort nonce, fix before GA" framing — the practical state is unbounded replay of a credential-validating request. Fix: require a short-lived `exp` (and an `iat` max-age) on inbound JWTs, and hard-enforce single-use `request_nonce` (reject when the store rejects). - -**Partially addressed in #46942.** `request_nonce` is now hard-enforced and consumed before dispatch for all token flows (password login, key request, key exchange) — a replayed JWS is rejected. Requiring `exp`/`iat` max-age on inbound JWTs remains open (#47122 covers JWT validation cleanup). - ### HIGH [deploy] — Key replacement on registration enables device takeover Compounds the unauthenticated-registration limitation. `SetOrUpdatePSSODevice` (`server/datastore/mysql/apple_psso.go`) deletes a host's existing `key_ids` and inserts the caller's on a plain upsert. The `IOPlatformUUID` is not secret (it appears in osquery results, MDM inventory, logs), so an unauthenticated attacker who knows it can *overwrite* a legitimate device's registered signing/encryption keys and then drive `/token` as that host. The planned per-device registration token closes the spoofing primitive, but the key-replacement semantics are a separate decision: once a host has a registration, require the per-host token to match before replacing keys, and log/emit an activity on key replacement (rotation vs. takeover). -### HIGH [GA] — Inbound JWT algorithm not pinned - -`parsePSSOInboundJWT` (`ee/server/service/apple_psso_crypto.go`) calls `jwt.ParseWithClaims` without `jwt.WithValidMethods` and the keyfunc returns the EC key without asserting `token.Method`. Not exploitable as written (golang-jwt v4's type assertions reject HS/`none` against an `*ecdsa.PublicKey`), but it is one refactor away from an alg-confusion forgery. Fix: pass `jwt.WithValidMethods([]string{"ES256"})` and assert `*jwt.SigningMethodECDSA` in the keyfunc. Cheap hardening. - -### MEDIUM [deploy] — ROPG client is an SSRF / credential-redirection sink - -`idp_token_url` comes from admin-controlled `AppConfig` and is POSTed to with the user's plaintext password. There is no scheme/host validation, so whoever can edit config (or a future settings UI lacking validation) can point it at an internal address and harvest passwords. Validate `https://` and a non-internal host at config-write time, and confirm `fleethttp.NewClient()` enforces TLS verification for this client. Pair this validation with the live-reload work under *Admin configuration*. - -### MEDIUM [GA → now-active] — `key_context` is not bound to device or expiry - -The provisioned private key sealed into `key_context` (key request) is recoverable by any registered device replaying any captured `key_context`, and the blob carries no TTL (its payload `exp` is advisory and not re-checked on exchange). It is also sealed under a key HKDF-derived from the long-lived PSSO signing key, so signing-key rotation/re-mint silently invalidates all outstanding contexts, and a signing-key leak compromises every context ever issued (no forward secrecy). **Note:** the review rated this deferrable on the assumption the key-request/key-exchange path was not exercised by the Password flow — that is no longer true; the unlock-key exchange now runs during Password-mode registration, so treat this as active. Fix: bind `key_context` to the device `kid` and an expiry inside the sealed plaintext (e.g. as AAD), and reject on open if mismatched or expired. - -### MEDIUM [GA] — Ad-hoc CA certificate issuance is sloppy - -`issuePSSOProvisionedCertificate` (`ee/server/service/apple_psso.go`) regenerates a self-signed, unconstrained, 10-year signing CA on every key request with fixed serial `1`. Generate the CA once (persist alongside the signing key), use random serials for both CA and leaf, and add EKU/name constraints scoping its use. - ### LOW [GA] — Hardcoded developer Team/bundle IDs in the AASA `teamID*`/`bundleID*` constants (`ee/server/service/apple_psso.go`) are baked into the public, always-served `apple-app-site-association`. They leak Fleet-developer identifiers and mis-bind for any deployer using their own signing identity. Make them config-driven before GA. diff --git a/ee/server/service/apple_psso.go b/ee/server/service/apple_psso.go index cde9e85b5bc..2922cacc83e 100644 --- a/ee/server/service/apple_psso.go +++ b/ee/server/service/apple_psso.go @@ -27,100 +27,112 @@ import ( jose "github.com/go-jose/go-jose/v3" ) -// pssoServiceState holds the lazily-loaded PSSO signing key. -// -// TODO(psso bootstrap): the current lazy-mint behavior runs on the first call -// to any method that reaches getOrMintPSSOSigningKey — most commonly -// PSSOJWKS, which is an unauthenticated public endpoint. That means an -// unauthenticated GET triggers a write + KMS roundtrip if the key doesn't -// exist yet. Acceptable for POC but worth revisiting before GA. Alternatives -// to consider: -// - mint when an admin first configures AppConfig.MDM.AppleAccountProvisioning -// - mint on the first device registration request -// - explicit `fleetctl psso bootstrap` step +// pssoServiceState caches the PSSO signing key and CA certificate after first +// load. Both are created in mdm_config_assets when the feature is first +// configured (bootstrapPSSOAssets, core side); this layer only loads them. type pssoServiceState struct { mu sync.Mutex signingKey *ecdsa.PrivateKey kid string + caCert *x509.Certificate } const ( pssoSigningAlg = "ES256" - // TODO: It's not clear if we need the overall app bundle ID or not either. We'll add it just in case - bundleID1 = "com.fleetdm.pssotesting" - bundleID2 = "com.fleetdm.pssotesting.extension" + // The host app bundle ID is included alongside the extension's just in case; + // PSSO validates against the extension, but listing both is harmless and matches + // what the IdPs analyzed do. + appBundleID = "com.fleetdm.fleet-desktop" + extensionBundleID = "com.fleetdm.fleet-desktop.pssoextension" - // TODO: Not sure if I actually need to use the team or my private user one so we'll define - // both for now... - teamID1 = "5K28R5ZUK5" - teamID2 = "B34KW9D28L" + fleetTeamID = "8VBZ3948LU" ) -// getOrMintPSSOSigningKey returns Fleet's PSSO signing key, loading it from -// mdm_config_assets or minting+persisting a fresh one if not present. -func (svc *Service) getOrMintPSSOSigningKey(ctx context.Context) (*ecdsa.PrivateKey, string, error) { +// getPSSOSigningKey loads Fleet's PSSO signing key from mdm_config_assets, +// caching it after first use. The key (and CA) are created when the feature is +// first configured (bootstrapPSSOAssets); a missing key here means the feature +// isn't configured, so this never mints — it returns an error. +func (svc *Service) getPSSOSigningKey(ctx context.Context) (*ecdsa.PrivateKey, string, error) { svc.pssoState.mu.Lock() defer svc.pssoState.mu.Unlock() + return svc.loadPSSOSigningKeyLocked(ctx) +} +// loadPSSOSigningKeyLocked is the cache-populating load shared by +// getPSSOSigningKey and getPSSOCA. Callers must hold pssoState.mu. +func (svc *Service) loadPSSOSigningKeyLocked(ctx context.Context) (*ecdsa.PrivateKey, string, error) { if svc.pssoState.signingKey != nil { return svc.pssoState.signingKey, svc.pssoState.kid, nil } - - // Try load. assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetPSSOSigningKey}, nil, ) - if err == nil { - asset, ok := assets[fleet.MDMAssetPSSOSigningKey] - if ok && len(asset.Value) > 0 { - key, kid, err := parsePSSOSigningKeyPEM(asset.Value) - if err != nil { - return nil, "", ctxerr.Wrap(ctx, err, "parse stored psso signing key") - } - svc.pssoState.signingKey = key - svc.pssoState.kid = kid - return key, kid, nil + if err != nil { + if isAssetNotFound(err) { + return nil, "", ctxerr.Wrap(ctx, err, "psso signing key not found; configure the feature first") } - } else if !isAssetNotFound(err) { return nil, "", ctxerr.Wrap(ctx, err, "get psso signing key asset") } - - // Mint a fresh key and persist. - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, "", ctxerr.Wrap(ctx, err, "generate psso signing key") + asset, ok := assets[fleet.MDMAssetPSSOSigningKey] + if !ok || len(asset.Value) == 0 { + return nil, "", ctxerr.New(ctx, "psso signing key asset is empty") } - pemBytes, kid, err := encodePSSOSigningKeyPEM(key) + key, kid, err := parsePSSOSigningKeyPEM(asset.Value) if err != nil { - return nil, "", ctxerr.Wrap(ctx, err, "encode psso signing key") - } - if err := svc.ds.InsertOrReplaceMDMConfigAsset(ctx, fleet.MDMConfigAsset{ - Name: fleet.MDMAssetPSSOSigningKey, - Value: pemBytes, - }); err != nil { - return nil, "", ctxerr.Wrap(ctx, err, "persist psso signing key") + return nil, "", ctxerr.Wrap(ctx, err, "parse stored psso signing key") } svc.pssoState.signingKey = key svc.pssoState.kid = kid return key, kid, nil } -// encodePSSOSigningKeyPEM serializes a P-256 private key to PEM and returns -// the bytes plus the kid (base64url-nopad SHA-256 of the DER-encoded public -// key). -func encodePSSOSigningKeyPEM(key *ecdsa.PrivateKey) ([]byte, string, error) { - der, err := x509.MarshalECPrivateKey(key) +// getPSSOCA loads the PSSO CA: the signing key (which is also the CA's private +// key) and the self-signed CA certificate, caching the certificate after first +// use. Like the signing key, the CA is created at first configuration and is +// never minted here. +func (svc *Service) getPSSOCA(ctx context.Context) (*ecdsa.PrivateKey, *x509.Certificate, error) { + svc.pssoState.mu.Lock() + defer svc.pssoState.mu.Unlock() + + caKey, _, err := svc.loadPSSOSigningKeyLocked(ctx) if err != nil { - return nil, "", err + return nil, nil, err } - pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) - kid, err := computeKID(&key.PublicKey) + if svc.pssoState.caCert != nil { + return caKey, svc.pssoState.caCert, nil + } + + assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, + []fleet.MDMAssetName{fleet.MDMAssetPSSOCACert}, + nil, + ) if err != nil { - return nil, "", err + if isAssetNotFound(err) { + return nil, nil, ctxerr.Wrap(ctx, err, "psso ca certificate not found; configure the feature first") + } + return nil, nil, ctxerr.Wrap(ctx, err, "get psso ca cert asset") + } + asset, ok := assets[fleet.MDMAssetPSSOCACert] + if !ok || len(asset.Value) == 0 { + return nil, nil, ctxerr.New(ctx, "psso ca cert asset is empty") } - return pemBytes, kid, nil + caCert, err := parsePSSOCACertPEM(asset.Value) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "parse stored psso ca cert") + } + svc.pssoState.caCert = caCert + return caKey, caCert, nil +} + +// parsePSSOCACertPEM decodes the stored PEM-wrapped PSSO CA certificate. +func parsePSSOCACertPEM(pemBytes []byte) (*x509.Certificate, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("psso ca cert: pem decode returned nil block") + } + return x509.ParseCertificate(block.Bytes) } func parsePSSOSigningKeyPEM(pemBytes []byte) (*ecdsa.PrivateKey, string, error) { @@ -572,7 +584,7 @@ func (svc *Service) handlePSSOKeyRequest(ctx context.Context, hostUUID string, c return nil, ctxerr.Wrap(ctx, err, "issue psso provisioned certificate") } - signingKey, _, err := svc.getOrMintPSSOSigningKey(ctx) + signingKey, _, err := svc.getPSSOSigningKey(ctx) if err != nil { return nil, ctxerr.Wrap(ctx, err, "load psso signing key") } @@ -580,7 +592,7 @@ func (svc *Service) handlePSSOKeyRequest(ctx context.Context, hostUUID string, c if err != nil { return nil, ctxerr.Wrap(ctx, err, "derive key_context key") } - keyContext, err := sealKeyContext(provisioned, kcKey) + keyContext, err := sealKeyContext(provisioned, hostUUID, pssoKeyPurposeUserUnlock, kcKey) if err != nil { return nil, ctxerr.Wrap(ctx, err, "seal key_context") } @@ -604,38 +616,20 @@ func (svc *Service) handlePSSOKeyRequest(ctx context.Context, hostUUID string, c } // issuePSSOProvisionedCertificate issues an X.509 certificate over a -// server-provisioned public key, signed by Fleet's PSSO signing key acting as -// a CA. This is the certificate returned in a key-request response; the device -// uses its public key for its half of the unlock-key Diffie-Hellman. +// server-provisioned public key, signed by Fleet's persisted PSSO CA. This is +// the certificate returned in a key-request response; the device uses its public +// key for its half of the unlock-key Diffie-Hellman. func (svc *Service) issuePSSOProvisionedCertificate(ctx context.Context, provisionedKey *ecdsa.PublicKey) ([]byte, error) { - caKey, _, err := svc.getOrMintPSSOSigningKey(ctx) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "load psso signing key for cert issuance") - } - - now := time.Now() - caTmpl := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: "Fleet PSSO CA"}, - NotBefore: now.Add(-time.Hour), - NotAfter: now.AddDate(10, 0, 0), - IsCA: true, - KeyUsage: x509.KeyUsageCertSign, - BasicConstraintsValid: true, - } - caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "create psso ca certificate") - } - caCert, err := x509.ParseCertificate(caDER) + caKey, caCert, err := svc.getPSSOCA(ctx) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "parse psso ca certificate") + return nil, ctxerr.Wrap(ctx, err, "load psso ca for cert issuance") } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return nil, ctxerr.Wrap(ctx, err, "generate psso cert serial") } + now := time.Now() devTmpl := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{CommonName: "Fleet PSSO Device Key"}, @@ -664,7 +658,7 @@ func (svc *Service) handlePSSOKeyExchange(ctx context.Context, hostUUID string, return nil, &fleet.BadRequestError{Message: "psso key exchange: missing other_publickey or key_context"} } - signingKey, _, err := svc.getOrMintPSSOSigningKey(ctx) + signingKey, _, err := svc.getPSSOSigningKey(ctx) if err != nil { return nil, ctxerr.Wrap(ctx, err, "load psso signing key") } @@ -672,10 +666,18 @@ func (svc *Service) handlePSSOKeyExchange(ctx context.Context, hostUUID string, if err != nil { return nil, ctxerr.Wrap(ctx, err, "derive key_context key") } - provisioned, err := openKeyContext(claims.KeyContext, kcKey) + kc, provisioned, err := openKeyContext(claims.KeyContext, kcKey) if err != nil { return nil, ctxerr.Wrap(ctx, err, "open key_context") } + // Bind the sealed key_context to the device: reject a context replayed by, or + // fetched onto, any device other than the one it was issued to. + if kc.HostUUID != hostUUID { + return nil, &fleet.BadRequestError{Message: "psso key exchange: key_context host mismatch"} + } + if kc.KeyPurpose != pssoKeyPurposeUserUnlock { + return nil, &fleet.BadRequestError{Message: "psso key exchange: unsupported key_context purpose"} + } otherRaw, err := decodeBase64Flexible(claims.OtherPublicKey) if err != nil { @@ -724,7 +726,7 @@ func (svc *Service) PSSOJWKS(ctx context.Context) ([]byte, error) { return nil, ¬FoundError{} } - key, kid, err := svc.getOrMintPSSOSigningKey(ctx) + key, kid, err := svc.getPSSOSigningKey(ctx) if err != nil { return nil, ctxerr.Wrap(ctx, err, "load psso signing key") } @@ -739,11 +741,10 @@ func (svc *Service) PSSOJWKS(ctx context.Context) ([]byte, error) { return json.Marshal(jwks) } -// pssoAASAEntry mirrors the apple-app-site-association shape Apple's -// framework consumes for PSSO. Only webcredentials.apps is required. +// pssoAASA mirrors the apple-app-site-association shape Apple's framework +// consumes for PSSO. PSSO validates the extension's authsrv: entitlement. type pssoAASA struct { - WebCredentials pssoAASAApps `json:"webcredentials"` - AuthSrv pssoAASAApps `json:"authsrv"` + AuthSrv pssoAASAApps `json:"authsrv"` } type pssoAASAApps struct { @@ -768,11 +769,8 @@ func (svc *Service) PSSOAASA(ctx context.Context) ([]byte, error) { return nil, ¬FoundError{} } - ids := []string{teamID1 + "." + bundleID1, teamID2 + "." + bundleID1, teamID1 + "." + bundleID2, teamID2 + "." + bundleID2} + ids := []string{fleetTeamID + "." + appBundleID, fleetTeamID + "." + extensionBundleID} doc := pssoAASA{ - WebCredentials: pssoAASAApps{ - Apps: ids, - }, AuthSrv: pssoAASAApps{ Apps: ids, }, diff --git a/ee/server/service/apple_psso_crypto.go b/ee/server/service/apple_psso_crypto.go index 3215d4bd442..2bd0d33f6ef 100644 --- a/ee/server/service/apple_psso_crypto.go +++ b/ee/server/service/apple_psso_crypto.go @@ -132,9 +132,16 @@ func (svc *Service) parsePSSOInboundJWT(ctx context.Context, jwtBytes []byte) (* return nil, nil, ctxerr.Wrap(ctx, err, "parse device signing pubkey") } - tok, err := jwt.ParseWithClaims(string(jwtBytes), &pssoTokenClaims{}, func(*jwt.Token) (any, error) { + // Pin the algorithm to ES256 (the only alg the Secure Enclave-backed + // extension signs with) and assert the ECDSA method in the keyfunc. Without + // this, a future refactor returning a non-EC key could open an alg-confusion + // forgery path even though golang-jwt's type assertions currently prevent it. + tok, err := jwt.ParseWithClaims(string(jwtBytes), &pssoTokenClaims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { + return nil, fmt.Errorf("psso jwt: unexpected signing method %q", t.Method.Alg()) + } return pub, nil - }) + }, jwt.WithValidMethods([]string{pssoSigningAlg})) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "verify psso jwt signature") } @@ -312,6 +319,7 @@ func encodeApplePartyInfo(fields ...[]byte) []byte { var b []byte var l [4]byte for _, f := range fields { + //nolint:gosec // dismiss G115, party-info fields are small (labels, 65-byte EC points, nonces), never near 2^32 binary.BigEndian.PutUint32(l[:], uint32(len(f))) b = append(b, l[:]...) b = append(b, f...) @@ -438,32 +446,70 @@ func deriveKeyContextKey(signingKey *ecdsa.PrivateKey) ([]byte, error) { return deriveSessionKey(ikm, []byte("fleetdm-psso-key-context-v1")) } -// sealKeyContext encrypts a provisioned EC private key into the opaque, -// base64 key_context returned in a key-request response. -func sealKeyContext(provisioned *ecdsa.PrivateKey, kcKey []byte) (string, error) { +// pssoKeyPurposeUserUnlock is the only key purpose Fleet provisions today: the +// offline FileVault/keychain unlock key. It's recorded in the sealed key_context +// so key exchange can validate it and future purposes can be distinguished. +const pssoKeyPurposeUserUnlock = "user_unlock" + +// pssoKeyContext is the plaintext sealed into the opaque key_context blob that +// rides between a key request and its matching key exchange. Binding the host +// UUID lets key exchange reject a context replayed by, or fetched onto, any +// device other than the one it was issued to; key_purpose leaves room to +// provision other key types later without reusing a context across purposes. +type pssoKeyContext struct { + HostUUID string `json:"host_uuid"` + KeyPurpose string `json:"key_purpose"` + ProvisionedKey string `json:"provisioned_key"` // base64 (std) DER of the EC private key +} + +// sealKeyContext seals the provisioned EC private key, bound to the device and +// key purpose, into the opaque base64 key_context returned in a key-request +// response. +func sealKeyContext(provisioned *ecdsa.PrivateKey, hostUUID, keyPurpose string, kcKey []byte) (string, error) { der, err := x509.MarshalECPrivateKey(provisioned) if err != nil { return "", err } - blob, err := buildSymmetricJWE(der, kcKey) + plaintext, err := json.Marshal(pssoKeyContext{ + HostUUID: hostUUID, + KeyPurpose: keyPurpose, + ProvisionedKey: base64.StdEncoding.EncodeToString(der), + }) + if err != nil { + return "", err + } + blob, err := buildSymmetricJWE(plaintext, kcKey) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(blob), nil } -// openKeyContext reverses sealKeyContext, recovering the provisioned private -// key the device echoed back in a key-exchange request. -func openKeyContext(keyContext string, kcKey []byte) (*ecdsa.PrivateKey, error) { +// openKeyContext reverses sealKeyContext, returning the sealed context metadata +// (for the caller to validate device/purpose binding) and the recovered +// provisioned private key the device echoed back in a key-exchange request. +func openKeyContext(keyContext string, kcKey []byte) (*pssoKeyContext, *ecdsa.PrivateKey, error) { blob, err := base64.StdEncoding.DecodeString(keyContext) if err != nil { - return nil, fmt.Errorf("decode key_context: %w", err) + return nil, nil, fmt.Errorf("decode key_context: %w", err) + } + plaintext, err := decryptSymmetricBlob(blob, kcKey) + if err != nil { + return nil, nil, fmt.Errorf("decrypt key_context: %w", err) + } + var kc pssoKeyContext + if err := json.Unmarshal(plaintext, &kc); err != nil { + return nil, nil, fmt.Errorf("unmarshal key_context: %w", err) + } + der, err := base64.StdEncoding.DecodeString(kc.ProvisionedKey) + if err != nil { + return nil, nil, fmt.Errorf("decode key_context provisioned_key: %w", err) } - der, err := decryptSymmetricBlob(blob, kcKey) + key, err := x509.ParseECPrivateKey(der) if err != nil { - return nil, fmt.Errorf("decrypt key_context: %w", err) + return nil, nil, fmt.Errorf("parse key_context provisioned_key: %w", err) } - return x509.ParseECPrivateKey(der) + return &kc, key, nil } // computeECDHShared returns the raw ECDH shared secret (P-256 X coordinate, 32 @@ -577,7 +623,7 @@ func decryptSymmetricBlob(blob []byte, sessionKey []byte) ([]byte, error) { // Fleet's PSSO signing key. Used to wrap payloads that must be authenticated // as coming from Fleet (e.g. claims responses). func (svc *Service) signServerJWT(ctx context.Context, claims jwt.Claims) ([]byte, error) { - key, kid, err := svc.getOrMintPSSOSigningKey(ctx) + key, kid, err := svc.getPSSOSigningKey(ctx) if err != nil { return nil, ctxerr.Wrap(ctx, err, "load signing key for psso server jwt") } diff --git a/ee/server/service/apple_psso_crypto_test.go b/ee/server/service/apple_psso_crypto_test.go index de55c052868..a650a07ab8d 100644 --- a/ee/server/service/apple_psso_crypto_test.go +++ b/ee/server/service/apple_psso_crypto_test.go @@ -189,11 +189,14 @@ func TestPSSO_KeyContextRoundTrip(t *testing.T) { provisioned, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - sealed, err := sealKeyContext(provisioned, kcKey) + const hostUUID = "ABCD-1234-host-uuid" + sealed, err := sealKeyContext(provisioned, hostUUID, pssoKeyPurposeUserUnlock, kcKey) require.NoError(t, err) - got, err := openKeyContext(sealed, kcKey) + kc, got, err := openKeyContext(sealed, kcKey) require.NoError(t, err) + assert.Equal(t, hostUUID, kc.HostUUID) + assert.Equal(t, pssoKeyPurposeUserUnlock, kc.KeyPurpose) want, err := x509.MarshalECPrivateKey(provisioned) require.NoError(t, err) gotDER, err := x509.MarshalECPrivateKey(got) @@ -205,7 +208,51 @@ func TestPSSO_KeyContextRoundTrip(t *testing.T) { require.NoError(t, err) otherKC, err := deriveKeyContextKey(other) require.NoError(t, err) - _, err = openKeyContext(sealed, otherKC) + _, _, err = openKeyContext(sealed, otherKC) + require.Error(t, err) +} + +// TestPSSO_InboundJWTAlgorithmPinned confirms the token endpoint accepts only +// ES256-signed device JWTs: an HS256 or "none" token presenting the same kid is +// rejected, closing the alg-confusion path. +func TestPSSO_InboundJWTAlgorithmPinned(t *testing.T) { + deviceKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + spki, err := x509.MarshalPKIXPublicKey(&deviceKey.PublicKey) + require.NoError(t, err) + pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: spki}) + + const kid = "device-signing-kid" + ds := new(mock.DataStore) + svc := &Service{ds: ds, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + ds.GetPSSOKeyFunc = func(_ context.Context, _ string) (*fleet.PSSOKey, error) { + return &fleet.PSSOKey{KID: kid, HostUUID: "host", KeyType: fleet.PSSOKeyTypeSigning, PEM: string(pubPEM)}, nil + } + + signed := func(method jwt.SigningMethod, key any) string { + tok := jwt.NewWithClaims(method, &pssoTokenClaims{RequestType: pssoRequestKey}) + tok.Header["kid"] = kid + s, err := tok.SignedString(key) + require.NoError(t, err) + return s + } + + // A valid ES256 token from the registered device verifies. + claims, gotKey, err := svc.parsePSSOInboundJWT(t.Context(), []byte(signed(jwt.SigningMethodES256, deviceKey))) + require.NoError(t, err) + assert.Equal(t, pssoRequestKey, claims.RequestType) + assert.Equal(t, fleet.PSSOKeyTypeSigning, gotKey.KeyType) + + // An HS256 token sharing the same kid is rejected (alg confusion). + _, _, err = svc.parsePSSOInboundJWT(t.Context(), []byte(signed(jwt.SigningMethodHS256, []byte("attacker-secret")))) + require.Error(t, err) + + // An unsigned ("none") token is rejected. + none := jwt.NewWithClaims(jwt.SigningMethodNone, &pssoTokenClaims{RequestType: pssoRequestKey}) + none.Header["kid"] = kid + noneStr, err := none.SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err) + _, _, err = svc.parsePSSOInboundJWT(t.Context(), []byte(noneStr)) require.Error(t, err) } diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 8335514d12d..67a5cc2f319 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -1042,6 +1042,10 @@ const ( // MDMAssetPSSOSigningKey is the EC P-256 private key Fleet uses to sign Platform SSO responses // and publishes via the PSSO JWKS endpoint for the Mac extension to verify. MDMAssetPSSOSigningKey MDMAssetName = "psso_signing_key" //nolint:gosec // private key, not a credential string + // MDMAssetPSSOCACert is the self-signed Platform SSO CA certificate Fleet uses + // to certify the provisioned unlock-key during key exchange. Its private key is + // MDMAssetPSSOSigningKey; both are minted once when the feature is first configured. + MDMAssetPSSOCACert MDMAssetName = "psso_ca_cert" // MDMAssetAppleAccountProvisioningIdPClientSecret is the OAuth ROPG IdP client // secret for the macOS account provisioning / Platform SSO feature. Stored // here (encrypted) rather than in the AppConfig JSON so the API never returns it. diff --git a/server/service/appconfig.go b/server/service/appconfig.go index c47fe50e5e0..137256b4fbe 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -1105,6 +1105,16 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle mergedAAP.OAuthIdPTokenURL.Value != oldAAP.OAuthIdPTokenURL.Value || mergedAAP.OAuthIdPClientID.Value != oldAAP.OAuthIdPClientID.Value + // Mint the PSSO signing key and CA the first time the feature is configured. + // Idempotent: existing assets are preserved (never recreated on reconfigure), + // and they are deliberately kept when the feature is disabled so a later + // re-enable reuses the same JWKS key and unlock-key CA. + if mergedAAP.Configured() { + if err := bootstrapPSSOAssets(ctx, svc.ds); err != nil { + return nil, ctxerr.Wrap(ctx, err, "bootstrap psso assets") + } + } + if err := svc.ds.SaveAppConfig(ctx, appConfig); err != nil { return nil, err } diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 35ab35c0ab6..99f61aee1a1 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -1775,6 +1775,12 @@ func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { tr.insertedSecret = &v return nil } + // Configuring the feature triggers bootstrapPSSOAssets, which mints and + // inserts the PSSO signing key + CA. The secret assertions don't touch + // these, so a no-op stub is enough to keep the bootstrap from panicking. + ds.InsertMDMConfigAssetsFunc = func(ctx context.Context, _ []fleet.MDMConfigAsset, _ sqlx.ExtContext) error { + return nil + } ds.DeleteMDMConfigAssetsByNameFunc = func(ctx context.Context, names []fleet.MDMAssetName) error { require.Equal(t, []fleet.MDMAssetName{fleet.MDMAssetAppleAccountProvisioningIdPClientSecret}, names) tr.deleted = true diff --git a/server/service/apple_psso.go b/server/service/apple_psso.go index ea0126b1ee9..e8ff07297e2 100644 --- a/server/service/apple_psso.go +++ b/server/service/apple_psso.go @@ -2,15 +2,24 @@ package service import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" "io" "log/slog" + "math/big" "net/http" "net/url" + "time" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/cryptoutil" ) // HTTP paths for the Apple Platform SSO endpoints. All but the AASA path live @@ -39,11 +48,8 @@ type pssoNonceRequest struct{} // DecodeBody drains and discards the request body. Apple's AppSSOAgent POSTs a // urlencoded grant_type=srv_challenge form to the nonce endpoint, but Fleet -// needs nothing from it — it just mints a nonce. Draining (rather than leaving -// it unread) keeps the connection reusable; the reader is already size-limited -// by the endpointer. The method must exist so the endpoint framework routes the -// form body here instead of falling through to JSON decoding, which rejects the -// form as malformed. +// needs nothing from it — it just mints a nonce. This method must exist so the +// endpoint framework routes the form body here instead of trying to decode as JSON. func (pssoNonceRequest) DecodeBody(_ context.Context, r io.Reader, _ url.Values, _ []*x509.Certificate) error { _, _ = io.Copy(io.Discard, r) return nil @@ -259,3 +265,119 @@ func (svc *Service) PSSOAASA(ctx context.Context) ([]byte, error) { svc.authz.SkipAuthorization(ctx) return nil, fleet.ErrMissingLicense } + +// ----- PSSO asset bootstrap ------------------------------------------------- +// +// The signing key and CA are pure crypto + datastore work, so they live here in +// core (callable from ModifyAppConfig) rather than in ee/. The ee service only +// loads them back, using the standard PEM encodings written below. + +// pssoCAValidYears is the lifetime of the self-signed Platform SSO CA, matching +// other CAs in fleet and minted once, when the feature is first configured. +const pssoCAValidYears = 10 + +// bootstrapPSSOAssets ensures the Platform SSO signing key and its CA certificate +// (which is signed by the signing key) exist in mdm_config_assets. It runs when the +// feature is configured and is idempotent: existing assets are never regenerated, so +// the signing key (published via JWKS) and the CA remain stable. +func bootstrapPSSOAssets(ctx context.Context, ds fleet.Datastore) error { + assets, err := ds.GetAllMDMConfigAssetsByName(ctx, + []fleet.MDMAssetName{fleet.MDMAssetPSSOSigningKey, fleet.MDMAssetPSSOCACert}, + nil, + ) + // A partial result (one asset present, the other missing) returns an error + // alongside the assets it did find; only a hard error with nothing usable is fatal. + if err != nil && !fleet.IsNotFound(err) && len(assets) == 0 { + return ctxerr.Wrap(ctx, err, "load psso assets") + } + + haveKey := false + haveCA := false + if assets != nil { + _, haveKey = assets[fleet.MDMAssetPSSOSigningKey] + _, haveCA = assets[fleet.MDMAssetPSSOCACert] + } + if haveKey && haveCA { + return nil + } + + // Throw an error because this is an inconsistent state - the CA was created apparently with a different signing key? + if haveCA && !haveKey { + return ctxerr.New(ctx, "psso ca certificate exists but signing key is missing") + } + + signingKey, err := pssoSigningKeyFromAssets(assets) + if err != nil { + return ctxerr.Wrap(ctx, err, "parse existing psso signing key") + } + + var toInsert []fleet.MDMConfigAsset + if signingKey == nil { + signingKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return ctxerr.Wrap(ctx, err, "generate psso signing key") + } + der, err := x509.MarshalECPrivateKey(signingKey) + if err != nil { + return ctxerr.Wrap(ctx, err, "marshal psso signing key") + } + toInsert = append(toInsert, fleet.MDMConfigAsset{ + Name: fleet.MDMAssetPSSOSigningKey, + Value: pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), + }) + } + if !haveCA { + caDER, err := selfSignPSSOCACert(signingKey) + if err != nil { + return ctxerr.Wrap(ctx, err, "create psso ca certificate") + } + toInsert = append(toInsert, fleet.MDMConfigAsset{ + Name: fleet.MDMAssetPSSOCACert, + Value: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), + }) + } + + if err := ds.InsertMDMConfigAssets(ctx, toInsert, nil); err != nil { + return ctxerr.Wrap(ctx, err, "insert psso assets") + } + return nil +} + +// pssoSigningKeyFromAssets parses the PSSO signing key out of a loaded asset map, +// returning (nil, nil) when it isn't present so the caller can mint a fresh one. +func pssoSigningKeyFromAssets(assets map[fleet.MDMAssetName]fleet.MDMConfigAsset) (*ecdsa.PrivateKey, error) { + asset, ok := assets[fleet.MDMAssetPSSOSigningKey] + if !ok || len(asset.Value) == 0 { + return nil, nil + } + block, _ := pem.Decode(asset.Value) + if block == nil { + return nil, errors.New("psso signing key: pem decode returned nil block") + } + return x509.ParseECPrivateKey(block.Bytes) +} + +// selfSignPSSOCACert self-signs a Platform SSO CA certificate over signingKey. +// Serial 1 matches Fleet's other self-signed CA roots (server/mdm/scep/depot): +// the CA is the only self-signed certificate this key ever produces, so the +// serial is unique by construction. +func selfSignPSSOCACert(signingKey *ecdsa.PrivateKey) ([]byte, error) { + subjectKeyID, err := cryptoutil.GenerateSubjectKeyID(&signingKey.PublicKey) + if err != nil { + return nil, err + } + now := time.Now() + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Fleet PSSO CA"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.AddDate(pssoCAValidYears, 0, 0), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + MaxPathLen: 0, + MaxPathLenZero: true, + SubjectKeyId: subjectKeyID, + } + return x509.CreateCertificate(rand.Reader, tmpl, tmpl, &signingKey.PublicKey, signingKey) +} diff --git a/server/service/apple_psso_test.go b/server/service/apple_psso_test.go index cd6fd290081..09fa407c7fe 100644 --- a/server/service/apple_psso_test.go +++ b/server/service/apple_psso_test.go @@ -1,11 +1,21 @@ package service import ( + "context" + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "errors" "net/http/httptest" "net/url" "strings" "testing" + "time" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -25,11 +35,11 @@ func TestPSSONonceEndpointAcceptsFormBody(t *testing.T) { } func TestPSSORegistrationRequestDecodeBody(t *testing.T) { - pem := "-----BEGIN PUBLIC KEY-----\nMFkw+abc/def=\n-----END PUBLIC KEY-----" + pubPEM := "-----BEGIN PUBLIC KEY-----\nMFkw+abc/def=\n-----END PUBLIC KEY-----" form := url.Values{} form.Set("device_uuid", "A72B07D0-2E08-45CE-9423-1FCAFFAEC390") - form.Set("device_signing_key", pem) - form.Set("device_encryption_key", pem) + form.Set("device_signing_key", pubPEM) + form.Set("device_encryption_key", pubPEM) form.Set("signing_key_id", "sign-kid") form.Set("encryption_key_id", "enc-kid") @@ -38,8 +48,8 @@ func TestPSSORegistrationRequestDecodeBody(t *testing.T) { require.NoError(t, err) require.Equal(t, "A72B07D0-2E08-45CE-9423-1FCAFFAEC390", req.DeviceUUID) // PEM survives urlencoding round trip: '+', '/', '=' and newlines intact. - require.Equal(t, pem, req.DeviceSigningKey) - require.Equal(t, pem, req.DeviceEncryptionKey) + require.Equal(t, pubPEM, req.DeviceSigningKey) + require.Equal(t, pubPEM, req.DeviceEncryptionKey) require.Equal(t, "sign-kid", req.SigningKeyID) require.Equal(t, "enc-kid", req.EncryptionKeyID) } @@ -74,3 +84,114 @@ func TestPSSOTokenRequestDecodeBody(t *testing.T) { require.Error(t, err) }) } + +type pssoTestNotFoundError struct{} + +func (pssoTestNotFoundError) Error() string { return "not found" } +func (pssoTestNotFoundError) IsNotFound() bool { return true } + +// pssoBootstrapMock wires a mock datastore over an in-memory asset map so the +// bootstrap can be exercised without MySQL. GetAll returns a not-found error +// when nothing matches (mirroring the real datastore) and Insert appends. +func pssoBootstrapMock(store map[fleet.MDMAssetName]fleet.MDMConfigAsset) *mock.DataStore { + ds := new(mock.DataStore) + ds.GetAllMDMConfigAssetsByNameFunc = func(_ context.Context, names []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + out := map[fleet.MDMAssetName]fleet.MDMConfigAsset{} + for _, n := range names { + if a, ok := store[n]; ok { + out[n] = a + } + } + if len(out) == 0 { + return nil, pssoTestNotFoundError{} + } + if len(out) < len(names) { + return out, errors.New("partial result") + } + return out, nil + } + ds.InsertMDMConfigAssetsFunc = func(_ context.Context, assets []fleet.MDMConfigAsset, _ sqlx.ExtContext) error { + for _, a := range assets { + store[a.Name] = a + } + return nil + } + return ds +} + +func parsePEMSigningKey(t *testing.T, value []byte) *ecdsa.PrivateKey { + t.Helper() + block, _ := pem.Decode(value) + require.NotNil(t, block) + key, err := x509.ParseECPrivateKey(block.Bytes) + require.NoError(t, err) + return key +} + +func parsePEMCert(t *testing.T, value []byte) *x509.Certificate { + t.Helper() + block, _ := pem.Decode(value) + require.NotNil(t, block) + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + return cert +} + +func TestBootstrapPSSOAssets(t *testing.T) { + ctx := context.Background() + + t.Run("creates signing key and CA when both absent", func(t *testing.T) { + store := map[fleet.MDMAssetName]fleet.MDMConfigAsset{} + ds := pssoBootstrapMock(store) + + require.NoError(t, bootstrapPSSOAssets(ctx, ds)) + require.True(t, ds.InsertMDMConfigAssetsFuncInvoked) + require.Contains(t, store, fleet.MDMAssetPSSOSigningKey) + require.Contains(t, store, fleet.MDMAssetPSSOCACert) + + signingKey := parsePEMSigningKey(t, store[fleet.MDMAssetPSSOSigningKey].Value) + caCert := parsePEMCert(t, store[fleet.MDMAssetPSSOCACert].Value) + + assert.True(t, caCert.IsCA) + // The CA is self-signed by the signing key, so its public key is the + // signing key's public key. + caPub, ok := caCert.PublicKey.(*ecdsa.PublicKey) + require.True(t, ok) + assert.True(t, caPub.Equal(&signingKey.PublicKey)) + require.NoError(t, caCert.CheckSignatureFrom(caCert)) + assert.WithinDuration(t, time.Now().AddDate(pssoCAValidYears, 0, 0), caCert.NotAfter, 24*time.Hour) + }) + + t.Run("no-op when both already exist", func(t *testing.T) { + store := map[fleet.MDMAssetName]fleet.MDMConfigAsset{} + require.NoError(t, bootstrapPSSOAssets(ctx, pssoBootstrapMock(store))) + seededKey := store[fleet.MDMAssetPSSOSigningKey].Value + seededCA := store[fleet.MDMAssetPSSOCACert].Value + + ds := pssoBootstrapMock(store) + require.NoError(t, bootstrapPSSOAssets(ctx, ds)) + // Nothing re-inserted, and the existing assets are untouched. + assert.False(t, ds.InsertMDMConfigAssetsFuncInvoked) + assert.Equal(t, seededKey, store[fleet.MDMAssetPSSOSigningKey].Value) + assert.Equal(t, seededCA, store[fleet.MDMAssetPSSOCACert].Value) + }) + + t.Run("creates only the CA over the existing key when CA is missing", func(t *testing.T) { + store := map[fleet.MDMAssetName]fleet.MDMConfigAsset{} + // Seed a signing key only (e.g. a POC instance pre-dating the CA asset). + require.NoError(t, bootstrapPSSOAssets(ctx, pssoBootstrapMock(store))) + existingKeyPEM := store[fleet.MDMAssetPSSOSigningKey].Value + delete(store, fleet.MDMAssetPSSOCACert) + + ds := pssoBootstrapMock(store) + require.NoError(t, bootstrapPSSOAssets(ctx, ds)) + + // The signing key is preserved (not regenerated) and the new CA is signed by it. + assert.Equal(t, existingKeyPEM, store[fleet.MDMAssetPSSOSigningKey].Value) + signingKey := parsePEMSigningKey(t, existingKeyPEM) + caCert := parsePEMCert(t, store[fleet.MDMAssetPSSOCACert].Value) + caPub, ok := caCert.PublicKey.(*ecdsa.PublicKey) + require.True(t, ok) + assert.True(t, caPub.Equal(&signingKey.PublicKey)) + }) +}