Skip to content

Commit a334816

Browse files
JordanMontgomeryCopilotMagnusHJensen
authored
macos password sync feature branch (#47422)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #45524 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually For unreleased bug fixes in a release candidate, one of: - [ ] Confirmed that the fix is not expected to adversely impact load test results - [ ] Alerted the release DRI if additional load testing is needed ## Database migrations - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). ## New Fleet configuration settings - [ ] Setting(s) is/are explicitly excluded from GitOps If you didn't check the box above, follow this checklist for GitOps-enabled settings: - [x] Verified that the setting is exported via `fleetctl generate-gitops` - [x] Verified the setting is documented in a separate PR to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - [x] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) - [x] Verified that any relevant UI is disabled when GitOps mode is enabled <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Apple Platform SSO (PSSO) for macOS with device registration, sign-in, and public discovery (JWKS + Apple app-site association) protected by single-use nonces. * Added Apple account provisioning (Platform SSO password sync) configuration with masked client-secret handling and GitOps support. * Added a host-scoped PSSO device registration token variable for Apple MDM profile generation. * **Bug Fixes** * Fixed macOS packaging to correctly build, embed, and sign the Platform SSO extension. * Resetting device Apple MDM data now also clears stored PSSO enrollment records. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Magnus Jensen <magnus@fleetdm.com>
1 parent 6d21671 commit a334816

76 files changed

Lines changed: 6790 additions & 54 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/fleet-desktop-macos-build.yml

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
name: Build Fleet Desktop (macOS)
22

3-
# Builds the native macOS Fleet Desktop app (apps/fleet-desktop-macos/), code signs
4-
# and notarizes it with Fleet's Developer ID certificates, and uploads the signed
3+
# Builds the native macOS Fleet Desktop app (apps/fleet-desktop-macos/) and its
4+
# embedded Platform SSO extension (FleetPSSOExtension.appex), code signs and
5+
# notarizes them with Fleet's Developer ID certificates, and uploads the signed
56
# .pkg as a workflow artifact. No GitHub Release is created.
7+
#
8+
# The app and extension carry managed Associated Domains entitlements
9+
# (com.apple.developer.associated-domains{,.mdm-managed}). Those are restricted
10+
# entitlements: codesign only honors them when a Developer ID provisioning
11+
# profile that grants them is embedded in the bundle. The profiles are provided
12+
# as base64 repo secrets (never committed) and embedded at sign time — see the
13+
# README's "Signing secrets" section.
14+
#
15+
# This workflow always signs and notarizes. If the certs or provisioning
16+
# profiles are unavailable (e.g. a fork PR that can't read secrets), it fails
17+
# loudly rather than producing an unsigned artifact.
618

719
on:
820
push:
@@ -36,6 +48,8 @@ env:
3648
# of Fleet's macOS artifacts (orbit Fleet Desktop, fleetd-base.pkg).
3749
APPLICATION_SIGNING_IDENTITY_SHA1: 604D877399AAEB7630A78B84F288E2D28A2EDE42
3850
INSTALLER_SIGNING_IDENTITY_SHA1: 4608F71FB42E1845C7FC9B2D2B6A7A8D11BBD940
51+
# Embedded SSO extension bundle (relative to Fleet Desktop.app/Contents).
52+
APPEX_REL_PATH: PlugIns/FleetPSSOExtension.appex
3953

4054
jobs:
4155
build:
@@ -52,7 +66,7 @@ jobs:
5266
with:
5367
persist-credentials: false
5468

55-
- name: Build app and create pkg
69+
- name: Build app (with embedded extension) and create pkg
5670
run: |
5771
chmod +x build.sh build-pkg.sh
5872
./build-pkg.sh
@@ -69,7 +83,7 @@ jobs:
6983
security default-keychain -s build.keychain
7084
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
7185
72-
# Developer ID Application certificate — signs the .app (codesign).
86+
# Developer ID Application certificate — signs the .app/.appex (codesign).
7387
echo "$APPLE_APPLICATION_CERTIFICATE" | base64 --decode > application.p12
7488
security import application.p12 -k build.keychain -P "$APPLE_APPLICATION_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
7589
rm application.p12
@@ -82,24 +96,71 @@ jobs:
8296
security set-key-partition-list -S apple-tool:,apple:,codesign:,productsign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
8397
security find-identity -vv
8498
85-
- name: Code sign app
99+
- name: Embed provisioning profiles
100+
env:
101+
APPLE_FLEET_DESKTOP_APP_PROFILE_B64: ${{ secrets.APPLE_FLEET_DESKTOP_APP_PROFILE_B64 }}
102+
APPLE_PSSO_EXT_PROFILE_B64: ${{ secrets.APPLE_PSSO_EXT_PROFILE_B64 }}
103+
run: |
104+
APP="build/Fleet Desktop.app"
105+
APPEX="$APP/Contents/$APPEX_REL_PATH"
106+
107+
if [ -z "$APPLE_FLEET_DESKTOP_APP_PROFILE_B64" ] || [ -z "$APPLE_PSSO_EXT_PROFILE_B64" ]; then
108+
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."
109+
exit 1
110+
fi
111+
112+
# Developer ID profiles authorizing the restricted entitlements.
113+
echo "$APPLE_PSSO_EXT_PROFILE_B64" | base64 --decode > "$APPEX/Contents/embedded.provisionprofile"
114+
echo "$APPLE_FLEET_DESKTOP_APP_PROFILE_B64" | base64 --decode > "$APP/Contents/embedded.provisionprofile"
115+
116+
- name: Verify profiles authorize the signing certificate
117+
run: |
118+
APP="build/Fleet Desktop.app"
119+
APPEX="$APP/Contents/$APPEX_REL_PATH"
120+
121+
# AMFI requires the signing certificate to be listed in the embedded
122+
# profile's DeveloperCertificates, or it SIGKILLs the app at launch.
123+
# codesign, Gatekeeper, and notarization all pass regardless — so
124+
# without this check a profile cut against the wrong cert produces a
125+
# signed, notarized pkg that silently won't launch. Even two certs from
126+
# the same team will result in a broken, unusable app - they must be the
127+
# same cert
128+
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)'
129+
for prof in "$APPEX/Contents/embedded.provisionprofile" "$APP/Contents/embedded.provisionprofile"; do
130+
echo "Checking $prof"
131+
if ! security cms -D -i "$prof" | python3 -c "$check" "$APPLICATION_SIGNING_IDENTITY_SHA1"; then
132+
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."
133+
exit 1
134+
fi
135+
done
136+
137+
- name: Code sign app and extension
86138
run: |
87-
BINARY_PATH="build/Fleet Desktop.app/Contents/MacOS/FleetDesktop"
139+
APP="build/Fleet Desktop.app"
140+
APPEX="$APP/Contents/$APPEX_REL_PATH"
88141
89-
# Sign the universal binary first, then the bundle (no --deep).
142+
# Sign inside-out: the embedded extension first, then the host app.
143+
# Each bundle is sealed with its own entitlements + embedded profile.
144+
codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \
145+
--options runtime --timestamp "$APPEX/Contents/MacOS/FleetPSSOExtension"
90146
codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \
91-
--options runtime --timestamp "$BINARY_PATH"
92-
codesign --verify --verbose "$BINARY_PATH"
147+
--options runtime --timestamp \
148+
--entitlements FleetPSSOExtension/FleetPSSOExtension.entitlements "$APPEX"
93149
94150
codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \
95-
--options runtime --timestamp "build/Fleet Desktop.app"
151+
--options runtime --timestamp "$APP/Contents/MacOS/FleetDesktop"
152+
codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \
153+
--options runtime --timestamp \
154+
--entitlements FleetDesktop/FleetDesktop.entitlements "$APP"
96155
97-
codesign --verify --deep --strict --verbose=2 "build/Fleet Desktop.app"
98-
codesign --display --verbose=4 "build/Fleet Desktop.app"
156+
codesign --verify --deep --strict --verbose=2 "$APP"
157+
codesign --display --verbose=4 "$APP"
158+
codesign --display --entitlements - "$APPEX"
99159
100160
- name: Rebuild pkg with signed app
101161
run: |
102-
# build-pkg.sh reuses the already-signed app (ditto preserves the signature).
162+
# build-pkg.sh reuses the already-signed app (ditto preserves the
163+
# signature and the embedded, signed appex).
103164
./build-pkg.sh
104165
105166
- name: Sign pkg
@@ -156,6 +217,7 @@ jobs:
156217
spctl --assess --type install --verbose "$PKG_PATH"
157218
158219
- name: Cleanup keychain
220+
if: always()
159221
run: security delete-keychain build.keychain || true
160222

161223
- name: Upload pkg artifact
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.application-identifier</key>
6+
<string>8VBZ3948LU.com.fleetdm.fleet-desktop</string>
7+
<key>com.apple.developer.team-identifier</key>
8+
<string>8VBZ3948LU</string>
9+
<key>com.apple.developer.associated-domains</key>
10+
<array/>
11+
<key>com.apple.developer.associated-domains.mdm-managed</key>
12+
<true/>
13+
</dict>
14+
</plist>
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// AuthenticationViewController+Networking.swift
2+
// FleetPSSOExtension
3+
//
4+
// Direct URLSession networking against the Fleet server. Device registration
5+
// must POST directly (no web view): Password-mode registration has no browser
6+
// auth step, and the prior(to macOS 26) pattern of using a WKWebView isn't
7+
// functional during Setup Assistant (EnableRegistrationDuringSetup) — this was
8+
// found to silently skip registration, so the later token request presents an
9+
// unregistered key.
10+
//
11+
// TODO: If we ever want to add support for a browser-based registration flow(e.g.
12+
// in lieu of, or when the registration token is bad) we may need to figure out how
13+
// to support a web view
14+
15+
import Foundation
16+
import Security
17+
18+
extension AuthenticationViewController {
19+
20+
// loginRequestEncryptionKey fetches Fleet's JWKS and returns the public key
21+
// marked use:"enc" as a SecKey, or nil if the request fails or no such key
22+
// is published. macOS uses it to encrypt the password into the login
23+
// assertion. Fleet always publishes an encryption key, so the caller treats
24+
// nil as fatal rather than proceeding with password encryption disabled.
25+
func loginRequestEncryptionKey(jwksURL: URL) async -> SecKey? {
26+
guard let (data, resp) = try? await URLSession.shared.data(from: jwksURL),
27+
let http = resp as? HTTPURLResponse,
28+
(200...299).contains(http.statusCode),
29+
let jwks = try? JSONDecoder().decode(JWKSet.self, from: data)
30+
else { return nil }
31+
32+
for jwk in jwks.keys where jwk.use == "enc" {
33+
if let key = jwk.ecPublicSecKey() {
34+
return key
35+
}
36+
}
37+
return nil
38+
}
39+
40+
// postDeviceRegistration POSTs the registration payload to Fleet and
41+
// returns true on a 2xx response.
42+
func postDeviceRegistration(payload: [String: String]) async -> Bool {
43+
guard let endpoint = registrationEndpointURL else { return false }
44+
var req = URLRequest(url: endpoint)
45+
req.httpMethod = "POST"
46+
req.setValue("application/x-www-form-urlencoded",
47+
forHTTPHeaderField: "Content-Type")
48+
let items = payload.map { URLQueryItem(name: $0.key, value: $0.value) }
49+
req.httpBody = formURLEncodedBody(items)
50+
guard let (_, resp) = try? await URLSession.shared.data(for: req),
51+
let http = resp as? HTTPURLResponse else {
52+
return false
53+
}
54+
return (200...299).contains(http.statusCode)
55+
}
56+
57+
// formURLEncodedBody serializes query items as an x-www-form-urlencoded
58+
// body, percent-encoding everything outside the RFC 3986 unreserved set so
59+
// '+', '/', '=', spaces and newlines in PEM values survive intact.
60+
private func formURLEncodedBody(_ items: [URLQueryItem]) -> Data {
61+
var allowed = CharacterSet.alphanumerics
62+
allowed.insert(charactersIn: "-._~")
63+
let pairs = items.map { item -> String in
64+
let name = item.name.addingPercentEncoding(withAllowedCharacters: allowed) ?? item.name
65+
let value = (item.value ?? "").addingPercentEncoding(withAllowedCharacters: allowed) ?? ""
66+
return "\(name)=\(value)"
67+
}
68+
return Data(pairs.joined(separator: "&").utf8)
69+
}
70+
}
71+
72+
// JWKSet / JWK model just enough of RFC 7517 to pull an EC public key out of
73+
// Fleet's PSSO JWKS.
74+
private struct JWKSet: Decodable {
75+
let keys: [JWK]
76+
}
77+
78+
private struct JWK: Decodable {
79+
let kty: String
80+
let crv: String?
81+
let x: String?
82+
let y: String?
83+
let use: String?
84+
85+
// ecPublicSecKey rebuilds the ANSI X9.63 uncompressed point (0x04 || X || Y)
86+
// from the JWK coordinates and imports it as a P-256 public SecKey — the form
87+
// loginRequestEncryptionPublicKey expects.
88+
func ecPublicSecKey() -> SecKey? {
89+
guard kty == "EC", crv == "P-256",
90+
let xStr = x, let yStr = y,
91+
let xData = Data(base64URLEncoded: xStr),
92+
let yData = Data(base64URLEncoded: yStr),
93+
xData.count == 32, yData.count == 32
94+
else { return nil }
95+
var raw = Data([0x04])
96+
raw.append(xData)
97+
raw.append(yData)
98+
let attrs: [String: Any] = [
99+
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
100+
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
101+
]
102+
return SecKeyCreateWithData(raw as CFData, attrs as CFDictionary, nil)
103+
}
104+
}
105+
106+
extension Data {
107+
func base64URLEncodedString() -> String {
108+
base64EncodedString()
109+
.replacingOccurrences(of: "+", with: "-")
110+
.replacingOccurrences(of: "/", with: "_")
111+
.replacingOccurrences(of: "=", with: "")
112+
}
113+
114+
// base64URLEncoded decodes the base64url (RFC 4648 §5) coordinates in a JWK,
115+
// re-padding to a multiple of 4 for Foundation's base64 decoder.
116+
init?(base64URLEncoded input: String) {
117+
var s = input
118+
.replacingOccurrences(of: "-", with: "+")
119+
.replacingOccurrences(of: "_", with: "/")
120+
let remainder = s.count % 4
121+
if remainder > 0 {
122+
s.append(String(repeating: "=", count: 4 - remainder))
123+
}
124+
self.init(base64Encoded: s)
125+
}
126+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// AuthenticationViewController+PSSO.swift
2+
// FleetPSSOExtension
3+
//
4+
// ASAuthorizationProviderExtensionRegistrationHandler conformance. The
5+
// framework hands us a login manager; we ask it for the shared device
6+
// signing + encryption keys, build a registration payload, and configure
7+
// the SSO endpoints from extensionData supplied by the configuration
8+
// profile. Apple owns the private key material — we only see SecKey
9+
// handles and derive public PEMs from them.
10+
11+
import AuthenticationServices
12+
import CryptoKit
13+
import Foundation
14+
import IOKit
15+
import Security
16+
17+
@available(macOS 14.0, *)
18+
extension AuthenticationViewController:
19+
ASAuthorizationProviderExtensionRegistrationHandler {
20+
21+
func beginDeviceRegistration(
22+
loginManager: ASAuthorizationProviderExtensionLoginManager,
23+
options: ASAuthorizationProviderExtensionRequestOptions,
24+
completion: @escaping (ASAuthorizationProviderExtensionRegistrationResult) -> Void
25+
) {
26+
self.loginManager = loginManager
27+
guard let signKey = loginManager.key(for: .sharedDeviceSigning),
28+
let encKey = loginManager.key(for: .sharedDeviceEncryption) else {
29+
completion(.failed)
30+
return
31+
}
32+
guard let registrationToken = loginManager.registrationToken, !registrationToken.isEmpty else {
33+
completion(.failed)
34+
return
35+
}
36+
// applyLoginConfiguration fetches the server's encryption key over HTTP,
37+
// so it runs on the Task alongside the registration POST. Report success
38+
// only once Fleet has stored the keys, so the framework can't proceed to
39+
// authentication with an unregistered key (which 404s at the token
40+
// endpoint). This is what makes the Setup Assistant flow work.
41+
Task {
42+
do {
43+
try await self.applyLoginConfiguration(loginManager)
44+
} catch {
45+
completion(.failed)
46+
return
47+
}
48+
let payload = self.registrationPayload(
49+
signing: signKey,
50+
encryption: encKey,
51+
registrationToken: registrationToken)
52+
// A failed key export leaves empty PEM/KID fields (see keyID /
53+
// pemRepresentation). Refuse to register an incomplete payload
54+
// instead of POSTing keys the server can only reject.
55+
let requiredFields = ["device_signing_key", "device_encryption_key",
56+
"signing_key_id", "encryption_key_id"]
57+
guard requiredFields.allSatisfy({ !(payload[$0] ?? "").isEmpty }) else {
58+
completion(.failed)
59+
return
60+
}
61+
let ok = await self.postDeviceRegistration(payload: payload)
62+
completion(ok ? .success : .failed)
63+
}
64+
}
65+
66+
func beginUserRegistration(
67+
loginManager: ASAuthorizationProviderExtensionLoginManager,
68+
userName: String?,
69+
method: ASAuthorizationProviderExtensionAuthenticationMethod,
70+
options: ASAuthorizationProviderExtensionRequestOptions,
71+
completion: @escaping (ASAuthorizationProviderExtensionRegistrationResult) -> Void
72+
) {
73+
// Persist the user login configuration. Without this the framework
74+
// reports "no user configuration for user" and never finishes binding
75+
// the PSSO user to the local account, so the unlock-key/SecureToken
76+
// setup stays incomplete and key unwrap fails at login ("previous
77+
// password required"). For password mode saving the config is all the
78+
// extension needs to do.
79+
guard let userName, !userName.isEmpty else {
80+
completion(.failed)
81+
return
82+
}
83+
let config = ASAuthorizationProviderExtensionUserLoginConfiguration(loginUserName: userName)
84+
do {
85+
try loginManager.saveUserLoginConfiguration(config)
86+
} catch {
87+
completion(.failed)
88+
return
89+
}
90+
completion(.success)
91+
}
92+
93+
func protocolVersion() -> ASAuthorizationProviderExtensionPlatformSSOProtocolVersion {
94+
.version2_0
95+
}
96+
97+
func supportedGrantTypes() -> ASAuthorizationProviderExtensionSupportedGrantTypes {
98+
.password
99+
}
100+
}

0 commit comments

Comments
 (0)