From 0f98e84bc875ed7f8b8f7166056272d1e21c6d06 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Mon, 29 Jul 2024 11:02:27 -0500 Subject: [PATCH] Add minimum os version requirements to DEP enrollment flow (#20722) --- changes/19674-dep-min-os-version | 1 + server/datastore/mysql/apple_mdm.go | 58 ++++++ server/datastore/mysql/apple_mdm_test.go | 172 ++++++++++++++++++ server/fleet/apple_mdm.go | 50 ++++++ server/fleet/datastore.go | 4 + server/fleet/service.go | 3 + server/mdm/apple/AppleIncRootCertificate.cer | Bin 0 -> 1215 bytes server/mdm/apple/deviceinfo.go | 179 +++++++++++++++++++ server/mock/datastore_mock.go | 12 ++ server/service/apple_mdm.go | 110 ++++++++++++ server/service/apple_mdm_test.go | 78 ++++++++ 11 files changed, 667 insertions(+) create mode 100644 changes/19674-dep-min-os-version create mode 100644 server/mdm/apple/AppleIncRootCertificate.cer create mode 100644 server/mdm/apple/deviceinfo.go diff --git a/changes/19674-dep-min-os-version b/changes/19674-dep-min-os-version new file mode 100644 index 0000000000..b9adefe9ec --- /dev/null +++ b/changes/19674-dep-min-os-version @@ -0,0 +1 @@ +- Updated MDM features to enforce minimum OS version settings during Apple Automated Device Enrollment (ADE). diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index a0e95f7d75..0bb49993b6 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -4603,3 +4603,61 @@ LIMIT 500 return deviceUUIDs, nil } + +func (ds *Datastore) GetMDMAppleOSUpdatesSettingsByHostSerial(ctx context.Context, serial string) (*fleet.AppleOSUpdateSettings, error) { + stmt := ` +SELECT + team_id, platform +FROM + hosts h +JOIN + host_dep_assignments hdep ON h.id = host_id +WHERE + hardware_serial = ? AND deleted_at IS NULL +LIMIT 1` + + var dest struct { + TeamID *uint `db:"team_id"` + Platform string `db:"platform"` + } + if err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, stmt, serial); err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting team id for host") + } + + var settings fleet.AppleOSUpdateSettings + if dest.TeamID == nil { + // use the global settings + ac, err := ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting app config for os update settings") + } + switch dest.Platform { + case "ios": + settings = ac.MDM.IOSUpdates + case "ipados": + settings = ac.MDM.IPadOSUpdates + case "darwin": + settings = ac.MDM.MacOSUpdates + default: + return nil, ctxerr.New(ctx, fmt.Sprintf("unsupported platform %s", dest.Platform)) + } + } else { + // use the team settings + tm, err := ds.TeamWithoutExtras(ctx, *dest.TeamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting team os update settings") + } + switch dest.Platform { + case "ios": + settings = tm.Config.MDM.IOSUpdates + case "ipados": + settings = tm.Config.MDM.IPadOSUpdates + case "darwin": + settings = tm.Config.MDM.MacOSUpdates + default: + return nil, ctxerr.New(ctx, fmt.Sprintf("unsupported platform %s", dest.Platform)) + } + } + + return &settings, nil +} diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 35a8a87497..53c8423809 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -6001,6 +6001,7 @@ func testGetHostUUIDsWithPendingMDMAppleCommands(t *testing.T, ds *Datastore) { require.NoError(t, err) require.ElementsMatch(t, []string{hosts[1].UUID, hosts[2].UUID}, uuids) } + func testHostDetailsMDMProfilesIOSIPadOS(t *testing.T, ds *Datastore) { ctx := context.Background() @@ -6143,3 +6144,174 @@ func testHostDetailsMDMProfilesIOSIPadOS(t *testing.T, ds *Datastore) { require.Equal(t, fleet.MDMDeliveryVerified, *gotProfs[0].Status) } } + +func TestGetMDMAppleOSUpdatesSettingsByHostSerial(t *testing.T) { + ds := CreateMySQLDS(t) + defer ds.Close() + + keys := []string{"ios", "ipados", "macos"} + devicesByKey := map[string]godep.Device{ + "ios": {SerialNumber: "dep-serial-ios-updates", DeviceFamily: "iPhone"}, + "ipados": {SerialNumber: "dep-serial-ipados-updates", DeviceFamily: "iPad"}, + "macos": {SerialNumber: "dep-serial-macos-updates", DeviceFamily: "Mac"}, + } + + getConfigSettings := func(teamID uint, key string) *fleet.AppleOSUpdateSettings { + var settings fleet.AppleOSUpdateSettings + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + stmt := fmt.Sprintf(`SELECT json_value->'$.mdm.%s_updates' FROM app_config_json`, key) + if teamID > 0 { + stmt = fmt.Sprintf(`SELECT config->'$.mdm.%s_updates' FROM teams WHERE id = %d`, key, teamID) + } + var raw json.RawMessage + if err := sqlx.GetContext(context.Background(), q, &raw, stmt); err != nil { + return err + } + if err := json.Unmarshal(raw, &settings); err != nil { + return err + } + return nil + }) + return &settings + } + + setConfigSettings := func(teamID uint, key string, minVersion string) { + var mv *string + if minVersion != "" { + mv = &minVersion + } + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + stmt := fmt.Sprintf(`UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.%s_updates.minimum_version', ?)`, key) + if teamID > 0 { + stmt = fmt.Sprintf(`UPDATE teams SET config = JSON_SET(config, '$.mdm.%s_updates.minimum_version', ?) WHERE id = %d`, key, teamID) + } + if _, err := q.ExecContext(context.Background(), stmt, mv); err != nil { + return err + } + return nil + }) + } + + checkExpectedVersion := func(t *testing.T, gotSettings *fleet.AppleOSUpdateSettings, expectedVersion string) { + if expectedVersion == "" { + require.True(t, gotSettings.MinimumVersion.Set) + require.False(t, gotSettings.MinimumVersion.Valid) + require.Empty(t, gotSettings.MinimumVersion.Value) + } else { + require.True(t, gotSettings.MinimumVersion.Set) + require.True(t, gotSettings.MinimumVersion.Valid) + require.Equal(t, expectedVersion, gotSettings.MinimumVersion.Value) + } + } + + checkDevice := func(t *testing.T, teamID uint, key string, wantVersion string) { + checkExpectedVersion(t, getConfigSettings(teamID, key), wantVersion) + gotSettings, err := ds.GetMDMAppleOSUpdatesSettingsByHostSerial(context.Background(), devicesByKey[key].SerialNumber) + require.NoError(t, err) + checkExpectedVersion(t, gotSettings, wantVersion) + } + + // empty global settings to start + for _, key := range keys { + checkExpectedVersion(t, getConfigSettings(0, key), "") + } + + // ingest some test devices + n, _, err := ds.IngestMDMAppleDevicesFromDEPSync(context.Background(), []godep.Device{devicesByKey["ios"], devicesByKey["ipados"], devicesByKey["macos"]}) + require.NoError(t, err) + require.Equal(t, int64(3), n) + hostIDsByKey := map[string]uint{} + for key, device := range devicesByKey { + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + var hid uint + err = sqlx.GetContext(context.Background(), q, &hid, "SELECT id FROM hosts WHERE hardware_serial = ?", device.SerialNumber) + require.NoError(t, err) + hostIDsByKey[key] = hid + return nil + }) + } + + // not set in global config, so devics should return empty + checkDevice(t, 0, "ios", "") + checkDevice(t, 0, "ipados", "") + checkDevice(t, 0, "macos", "") + + // set the minimum version for ios + setConfigSettings(0, "ios", "17.1") + checkDevice(t, 0, "ios", "17.1") + checkDevice(t, 0, "ipados", "") // no change + checkDevice(t, 0, "macos", "") // no change + + // set the minimum version for ipados + setConfigSettings(0, "ipados", "17.2") + checkDevice(t, 0, "ios", "17.1") // no change + checkDevice(t, 0, "ipados", "17.2") + checkDevice(t, 0, "macos", "") // no change + + // set the minimum version for macos + setConfigSettings(0, "macos", "14.5") + checkDevice(t, 0, "ios", "17.1") // no change + checkDevice(t, 0, "ipados", "17.2") // no change + checkDevice(t, 0, "macos", "14.5") + + // create a team + team, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + // empty team settings to start + for _, key := range keys { + checkExpectedVersion(t, getConfigSettings(team.ID, key), "") + } + + // transfer ios and ipados to the team + err = ds.AddHostsToTeam(context.Background(), &team.ID, []uint{hostIDsByKey["ios"], hostIDsByKey["ipados"]}) + require.NoError(t, err) + + checkDevice(t, team.ID, "ios", "") // team settings are empty to start + checkDevice(t, team.ID, "ipados", "") // team settings are empty to start + checkDevice(t, 0, "macos", "14.5") // no change, still global + + setConfigSettings(team.ID, "ios", "17.3") + checkDevice(t, team.ID, "ios", "17.3") // team settings are set for ios + checkDevice(t, team.ID, "ipados", "") // team settings are empty for ipados + checkDevice(t, 0, "macos", "14.5") // no change, still global + + setConfigSettings(team.ID, "ipados", "17.4") + checkDevice(t, team.ID, "ios", "17.3") // no change in team settings for ios + checkDevice(t, team.ID, "ipados", "17.4") // team settings are set for ipados + checkDevice(t, 0, "macos", "14.5") // no change, still global + + // transfer macos to the team + err = ds.AddHostsToTeam(context.Background(), &team.ID, []uint{hostIDsByKey["macos"]}) + require.NoError(t, err) + checkDevice(t, team.ID, "macos", "") // team settings are empty for macos + + setConfigSettings(team.ID, "macos", "14.6") + checkDevice(t, team.ID, "macos", "14.6") // team settings are set for macos + + // create a non-DEP host + _, err = ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String("non-dep-osquery-id"), + NodeKey: ptr.String("non-dep-node-key"), + UUID: "non-dep-uuid", + Hostname: "non-dep-hostname", + Platform: "macos", + HardwareSerial: "non-dep-serial", + }) + + // non-DEP host should return not found + _, err = ds.GetMDMAppleOSUpdatesSettingsByHostSerial(context.Background(), "non-dep-serial") + require.ErrorIs(t, err, sql.ErrNoRows) + + // deleted DEP host should return not found + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(context.Background(), "UPDATE host_dep_assignments SET deleted_at = NOW() WHERE host_id = ?", hostIDsByKey["macos"]) + return err + }) + _, err = ds.GetMDMAppleOSUpdatesSettingsByHostSerial(context.Background(), devicesByKey["macos"].SerialNumber) + require.ErrorIs(t, err, sql.ErrNoRows) +} diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index e1cabf2533..b86434ac8e 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -841,3 +841,53 @@ type MDMAppleDDMActivation struct { ServerToken string `json:"ServerToken"` Type string `json:"Type"` // "com.apple.activation.simple" } + +// MDMAppleMachineInfo is a [device's information][1] sent as part of an MDM enrollment profile request +// +// [1]: https://developer.apple.com/documentation/devicemanagement/machineinfo +type MDMAppleMachineInfo struct { + IMEI string `plist:"IMEI,omitempty"` + Language string `plist:"LANGUAGE,omitempty"` + MDMCanRequestSoftwareUpdate bool `plist:"MDM_CAN_REQUEST_SOFTWARE_UPDATE"` + MEID string `plist:"MEID,omitempty"` + OSVersion string `plist:"OS_VERSION"` + PairingToken string `plist:"PAIRING_TOKEN,omitempty"` + Product string `plist:"PRODUCT"` + Serial string `plist:"SERIAL"` + SoftwareUpdateDeviceID string `plist:"SOFTWARE_UPDATE_DEVICE_ID,omitempty"` + SupplementalBuildVersion string `plist:"SUPPLEMENTAL_BUILD_VERSION,omitempty"` + SupplementalOSVersionExtra string `plist:"SUPPLEMENTAL_OS_VERSION_EXTRA,omitempty"` + UDID string `plist:"UDID"` + Version string `plist:"VERSION"` +} + +// MDMAppleSoftwareUpdateRequiredCode is the [code][1] specified by Apple to indicate that the device +// needs to perform a software update before enrollment and setup can proceed. +// +// [1]: https://developer.apple.com/documentation/devicemanagement/errorcodesoftwareupdaterequired +const MDMAppleSoftwareUpdateRequiredCode = "com.apple.softwareupdate.required" + +// MDMAppleSoftwareUpdateRequiredDetails is the [details][1] specified by Apple for the +// required software update. +// +// [1]: https://developer.apple.com/documentation/devicemanagement/errorcodesoftwareupdaterequired/details +type MDMAppleSoftwareUpdateRequiredDetails struct { + OSVersion string `json:"OSVersion"` + BuildVersion string `json:"BuildVersion"` +} + +// MDMAppleSoftwareUpdateRequired is the [error response][1] specified by Apple to indicate that the device +// needs to perform a software update before enrollment and setup can proceed. +// +// [1]: https://developer.apple.com/documentation/devicemanagement/errorcodesoftwareupdaterequired +type MDMAppleSoftwareUpdateRequired struct { + Code string `json:"code"` // "com.apple.softwareupdate.required" + Details MDMAppleSoftwareUpdateRequiredDetails `json:"details"` +} + +func NewMDMAppleSoftwareUpdateRequired(settings AppleOSUpdateSettings) *MDMAppleSoftwareUpdateRequired { + return &MDMAppleSoftwareUpdateRequired{ + Code: MDMAppleSoftwareUpdateRequiredCode, + Details: MDMAppleSoftwareUpdateRequiredDetails{OSVersion: settings.MinimumVersion.Value}, + } +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 40d332d133..ded8617c40 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1288,6 +1288,10 @@ type Datastore interface { // the provided value. MDMAppleSetPendingDeclarationsAs(ctx context.Context, hostUUID string, status *MDMDeliveryStatus, detail string) error + // GetMDMAppleOSUpdatesSettingsByHostSerial returns applicable Apple OS update settings (if any) + // for the host with the given serial number. The host must be DEP assigned to Fleet. + GetMDMAppleOSUpdatesSettingsByHostSerial(ctx context.Context, hostSerial string) (*AppleOSUpdateSettings, error) + // InsertMDMConfigAssets inserts MDM related config assets, such as SCEP and APNS certs and keys. InsertMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset) error diff --git a/server/fleet/service.go b/server/fleet/service.go index b4f8ab6958..eb8cb2b7e8 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -918,6 +918,9 @@ type Service interface { GetMDMManualEnrollmentProfile(ctx context.Context) ([]byte, error) + // CheckMDMAppleEnrollmentWithMinimumOSVersion checks if the minimum OS version is met for a MDM enrollment + CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx context.Context, m *MDMAppleMachineInfo) (*MDMAppleSoftwareUpdateRequired, error) + /////////////////////////////////////////////////////////////////////////////// // CronSchedulesService diff --git a/server/mdm/apple/AppleIncRootCertificate.cer b/server/mdm/apple/AppleIncRootCertificate.cer new file mode 100644 index 0000000000000000000000000000000000000000..8a9ff247419dd22a07c837ba7394aeabdd0f14e2 GIT binary patch literal 1215 zcmXqLV%crb#JqR`GZP~d5E<~YacZ@Bw0-AgWMpM!Fi0}wHsEAq4rO5zW(o~96gCh9 zakzxJ9199^QWZS&lJyML3{*gZ+`_UDLFd$>lFYQsBg2!4D>>yS-j;I@c+L7YuChh5U7`KHvAiEsOqE5wMI&W5Px=0B&b;#hyADPKr1x`dQTTp(jgCTo z!8UtFgP!fq=lSQ_e%AKXkUH`2+}53ZH{)ckownU-we|}?AHyW>jf!G=C0A{DZzqYZ zUR*fIJvj8>dVR;uKYl+hIQwj|k87R0Pjj_t->jT;Rj-bAq&^<-@B zm%W!-{69S|b&uzbviZg$sSC@eoYZAvW@KPo+{9P~43RPeK43h`@-s62XJG-R8#V)e z5MLO?XEk63QUIB4HrbfL%co zBPgB8DzG#$asX{)0b&Md!c0zKWi)8~WT3^yq0I(NqwGwKVsaTJB?ZM+`ugSN<$8&r zl&P1TpQ{gMB`4||G#-X4W-@5pCe^q(C^aWDF)uk)0hmHdGBS%5lHrLqRUxTTAu+E~ zp&+rS1js5bF3n9XR!B@vPAw>b=t%?WNd@6N1&|%Uq@D!K48=g%l*FPGg_6{wT%d-$ z6ouscyp&8(HYirePg5u@PSruNs30Gx7i1YwCER{crYR^&OfJa;IuB@ONosCtUP-YY za{2^jO7!e*{cX?eJDxY@8r; zW8atJ+3zl;@Sm>qH@UIM?q|jS>=W#7YAu_)gB31Y9ND;kmOoeaf9*e!%UL;V#2vx} zUUwk!R<>!CBEM2a=7;zgw~E zguTASugG_6SFxo3)|+Pa2irq$E}yy6$m#cutA+FG76xsX-aFYzMMzw9>OIdRD+ Xyc@&=R&`yy_2kb5PImJRrKO4hMS!&; literal 0 HcmV?d00001 diff --git a/server/mdm/apple/deviceinfo.go b/server/mdm/apple/deviceinfo.go new file mode 100644 index 0000000000..ebed273690 --- /dev/null +++ b/server/mdm/apple/deviceinfo.go @@ -0,0 +1,179 @@ +// The contents of this file have been copied and modified pursuant to the following +// license from the original source: +// https://github.com/korylprince/dep-webview-oidc/blob/2dd846a54fed04c16dd227b8c6c31665b4d0ebd8/header/header.go +// +// MIT License +// +// Copyright (c) 2023 Kory Prince +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package apple_mdm + +import ( + "bytes" + "crypto" + "crypto/rsa" + "crypto/sha1" // nolint:gosec // See comments regarding Apple's Root CA below + "crypto/x509" + _ "embed" + "encoding/base64" + "errors" + "fmt" + + "github.com/groob/plist" + "go.mozilla.org/pkcs7" +) + +const DeviceInfoHeader = "x-apple-aspen-deviceinfo" + +// appleRootCert is https://www.apple.com/appleca/AppleIncRootCertificate.cer +// +//go:embed AppleIncRootCertificate.cer +var appleRootCert []byte + +func newAppleRootCert() *x509.Certificate { + cert, err := x509.ParseCertificate(appleRootCert) + if err != nil { + panic(fmt.Errorf("could not parse cert: %w", err)) + } + return cert +} + +// appleRootCA is Apple's Root CA parsed to an *x509.Certificate +var appleRootCA = newAppleRootCert() + +// MachineInfo is a [device's information] sent as part of an MDM enrollment profile request +// +// [device's information]: https://developer.apple.com/documentation/devicemanagement/machineinfo +type MachineInfo struct { + IMEI string `plist:"IMEI,omitempty"` + Language string `plist:"LANGUAGE,omitempty"` + MDMCanRequestSoftwareUpdate bool `plist:"MDM_CAN_REQUEST_SOFTWARE_UPDATE"` + MEID string `plist:"MEID,omitempty"` + OSVersion string `plist:"OS_VERSION"` + PairingToken string `plist:"PAIRING_TOKEN,omitempty"` + Product string `plist:"PRODUCT"` + Serial string `plist:"SERIAL"` + SoftwareUpdateDeviceID string `plist:"SOFTWARE_UPDATE_DEVICE_ID,omitempty"` + SupplementalBuildVersion string `plist:"SUPPLEMENTAL_BUILD_VERSION,omitempty"` + SupplementalOSVersionExtra string `plist:"SUPPLEMENTAL_OS_VERSION_EXTRA,omitempty"` + UDID string `plist:"UDID"` + Version string `plist:"VERSION"` +} + +// verifyPKCS7SHA1RSA performs a manual SHA1withRSA verification, since it's deprecated in Go 1.18. +// If verifyChain is true, the signer certificate and its chain of certificates is verified against Apple's Root CA. +// Also note that the certificate validity time window of the signing cert is not checked, since the cert is expired. +// This follows guidance from Apple on the expired certificate. +func verifyPKCS7SHA1RSA(p7 *pkcs7.PKCS7, verifyChain bool) error { + if len(p7.Signers) == 0 { + return errors.New("not signed") + } + + // get signing cert + issuer := p7.Signers[0].IssuerAndSerialNumber + var signer *x509.Certificate + for _, cert := range p7.Certificates { + if bytes.Equal(cert.RawIssuer, issuer.IssuerName.FullBytes) && cert.SerialNumber.Cmp(issuer.SerialNumber) == 0 { + signer = cert + } + } + + // get sha1 hash of content + hashed := sha1.Sum(p7.Content) // nolint:gosec + + // verify content signature + signature := p7.Signers[0].EncryptedDigest + if err := rsa.VerifyPKCS1v15(signer.PublicKey.(*rsa.PublicKey), crypto.SHA1, hashed[:], signature); err != nil { + return fmt.Errorf("signature could not be verified: %w", err) + } + + if !verifyChain { + return nil + } + + // verify chain from signer to root + cert := signer +outer: + for { + // check if cert is signed by root + if bytes.Equal(cert.RawIssuer, appleRootCA.RawSubject) { + hashed := sha1.Sum(cert.RawTBSCertificate) // nolint:gosec + // check signature + if err := rsa.VerifyPKCS1v15(appleRootCA.PublicKey.(*rsa.PublicKey), crypto.SHA1, hashed[:], cert.Signature); err != nil { + return fmt.Errorf("could not verify root CA signature: %w", err) + } + return nil + } + for _, c := range p7.Certificates { + if cert == c { + continue + } + // check if cert is signed by intermediate cert in chain + if bytes.Equal(cert.RawIssuer, c.RawSubject) { + // check signature + hashed := sha1.Sum(cert.RawTBSCertificate) // nolint:gosec + if err := rsa.VerifyPKCS1v15(c.PublicKey.(*rsa.PublicKey), crypto.SHA1, hashed[:], cert.Signature); err != nil { + return fmt.Errorf("could not verify chained certificate signature: %w", err) + } + cert = c + continue outer + } + } + return errors.New("certificate root not found") + } +} + +// ParseDeviceinfo attempts to parse the provided string, assuming it to be the base64-encoded value +// of an x-apple-aspen-deviceinfo header. If successful, it returns the parsed *MachineInfo. If the +// verify parameter is specified as true, the signature is also verified against Apple's Root CA and +// an error will be returned if the signature is invalid. +// +// Warning: The information in this header, despite being signed by Apple PKI, shouldn't be trusted +// for device attestation or other security purposes. See the related [documentation] and referenced +// [article] for more information. +// +// [documentation]: https://github.com/korylprince/dep-webview-oidc/blob/2dd846a54fed04c16dd227b8c6c31665b4d0ebd8/docs/Architecture.md#x-apple-aspen-deviceinfo-header +// [article]: https://duo.com/labs/research/mdm-me-maybe +func ParseDeviceinfo(b64 string, verify bool) (*MachineInfo, error) { + buf, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, fmt.Errorf("could not decode base64: %w", err) + } + + p7, err := pkcs7.Parse(buf) + if err != nil { + return nil, fmt.Errorf("could not decode pkcs7: %w", err) + } + + // verify signature and certificate chain + if verify { + if err = verifyPKCS7SHA1RSA(p7, verify); err != nil { + return nil, fmt.Errorf("could not verify signature: %w", err) + } + } + + info := new(MachineInfo) + if err = plist.Unmarshal(p7.Content, info); err != nil { + return nil, fmt.Errorf("could not decode plist: %w", err) + } + + return info, nil +} diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index c57d665209..e16f8a9520 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -848,6 +848,8 @@ type MDMAppleStoreDDMStatusReportFunc func(ctx context.Context, hostUUID string, type MDMAppleSetPendingDeclarationsAsFunc func(ctx context.Context, hostUUID string, status *fleet.MDMDeliveryStatus, detail string) error +type GetMDMAppleOSUpdatesSettingsByHostSerialFunc func(ctx context.Context, hostSerial string) (*fleet.AppleOSUpdateSettings, error) + type InsertMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset) error type GetAllMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) @@ -2255,6 +2257,9 @@ type DataStore struct { MDMAppleSetPendingDeclarationsAsFunc MDMAppleSetPendingDeclarationsAsFunc MDMAppleSetPendingDeclarationsAsFuncInvoked bool + GetMDMAppleOSUpdatesSettingsByHostSerialFunc GetMDMAppleOSUpdatesSettingsByHostSerialFunc + GetMDMAppleOSUpdatesSettingsByHostSerialFuncInvoked bool + InsertMDMConfigAssetsFunc InsertMDMConfigAssetsFunc InsertMDMConfigAssetsFuncInvoked bool @@ -5402,6 +5407,13 @@ func (s *DataStore) MDMAppleSetPendingDeclarationsAs(ctx context.Context, hostUU return s.MDMAppleSetPendingDeclarationsAsFunc(ctx, hostUUID, status, detail) } +func (s *DataStore) GetMDMAppleOSUpdatesSettingsByHostSerial(ctx context.Context, hostSerial string) (*fleet.AppleOSUpdateSettings, error) { + s.mu.Lock() + s.GetMDMAppleOSUpdatesSettingsByHostSerialFuncInvoked = true + s.mu.Unlock() + return s.GetMDMAppleOSUpdatesSettingsByHostSerialFunc(ctx, hostSerial) +} + func (s *DataStore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error { s.mu.Lock() s.InsertMDMConfigAssetsFuncInvoked = true diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 3f1ef747a3..2118b13670 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -18,6 +18,7 @@ import ( "sync" "time" + "github.com/Masterminds/semver" "github.com/docker/go-units" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/optjson" @@ -1287,6 +1288,38 @@ func (svc *Service) EnqueueMDMAppleCommand( type mdmAppleEnrollRequest struct { Token string `query:"token"` EnrollmentReference string `query:"enrollment_reference,optional"` + MachineInfo *fleet.MDMAppleMachineInfo +} + +func (mdmAppleEnrollRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + decoded := mdmAppleEnrollRequest{} + + tok := r.URL.Query().Get("token") + if tok == "" { + return nil, &fleet.BadRequestError{ + Message: "token is required", + } + } + decoded.Token = tok + + er := r.URL.Query().Get("enrollment_reference") + decoded.EnrollmentReference = er + + // Parse the machine info from the request body + di := r.Header.Get("x-apple-aspen-deviceinfo") + if di != "" { + // extract x-apple-aspen-deviceinfo custom header from request + parsed, err := apple_mdm.ParseDeviceinfo(di, true) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "unable to parse deviceinfo header", + } + } + p := fleet.MDMAppleMachineInfo(*parsed) + decoded.MachineInfo = &p + } + + return &decoded, nil } func (r mdmAppleEnrollResponse) error() error { return r.Err } @@ -1296,9 +1329,20 @@ type mdmAppleEnrollResponse struct { // Profile field is used in hijackRender for the response. Profile []byte + + SoftwareUpdateRequired *fleet.MDMAppleSoftwareUpdateRequired } func (r mdmAppleEnrollResponse) hijackRender(ctx context.Context, w http.ResponseWriter) { + if r.SoftwareUpdateRequired != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + if err := json.NewEncoder(w).Encode(r.SoftwareUpdateRequired); err != nil { + encodeError(ctx, ctxerr.New(ctx, "failed to encode software update required"), w) + } + return + } + w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.Profile)), 10)) w.Header().Set("Content-Type", "application/x-apple-aspen-config") w.Header().Set("X-Content-Type-Options", "nosniff") @@ -1316,6 +1360,16 @@ func (r mdmAppleEnrollResponse) hijackRender(ctx context.Context, w http.Respons func mdmAppleEnrollEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*mdmAppleEnrollRequest) + sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, req.MachineInfo) + if err != nil { + return mdmAppleEnrollResponse{Err: err}, nil + } + if sur != nil { + return mdmAppleEnrollResponse{ + SoftwareUpdateRequired: sur, + }, nil + } + profile, err := svc.GetMDMAppleEnrollmentProfileByToken(ctx, req.Token, req.EnrollmentReference) if err != nil { return mdmAppleEnrollResponse{Err: err}, nil @@ -1376,6 +1430,62 @@ func (svc *Service) GetMDMAppleEnrollmentProfileByToken(ctx context.Context, tok return signed, nil } +func (svc *Service) CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx context.Context, m *fleet.MDMAppleMachineInfo) (*fleet.MDMAppleSoftwareUpdateRequired, error) { + // skipauth: The enroll profile endpoint is unauthenticated. + svc.authz.SkipAuthorization(ctx) + + if m == nil { + level.Info(svc.logger).Log("msg", "no machine info, skipping os version check") + return nil, nil + } + + if !m.MDMCanRequestSoftwareUpdate { + level.Info(svc.logger).Log("msg", "mdm cannot request software update, skipping os version check", "machine_info", *m) + return nil, nil + } + + // NOTE: Under the hood, the datastore is joining host_dep_assignments to the hosts table to + // look up DEP hosts by serial number. It grabs the team id and platform from the + // hosts table. Then it uses the team id to get either the global config or team config. + // Finally, it uses the platform to get os updates settings from the config for + // one of ios, ipados, or darwin, as applicable. There's a lot of assumptions going on here, not + // least of which is that the platform is correct in the hosts table. If the platform is wrong, + // we'll end up with a meaningless comparison of unrelated versions. We could potentially add + // some cross-check against the machine info to ensure that the platform of the host aligns with + // what we expect from the machine info. But that would involve work to derive the platform from + // the machine info (presumably from the product name, but that's not a 1:1 mapping). + settings, err := svc.ds.GetMDMAppleOSUpdatesSettingsByHostSerial(ctx, m.Serial) + if err != nil { + if fleet.IsNotFound(err) { + level.Info(svc.logger).Log("msg", "settings not found, skipping os version check", "machine_info", *m) + return nil, nil + } + return nil, ctxerr.Wrap(ctx, err, "get os updates settings") + } + + // TODO: confirm what this check should do + if !settings.MinimumVersion.Set || !settings.MinimumVersion.Valid || settings.MinimumVersion.Value == "" { + level.Info(svc.logger).Log("msg", "settings not set, skipping os version check", "machine_info", *m, "settings", settings) + return nil, nil + } + + want, err := semver.NewVersion(settings.MinimumVersion.Value) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "parsing minimum version") + } + + got, err := semver.NewVersion(m.OSVersion) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "parsing device os version") + } + + if got.LessThan(want) { + return fleet.NewMDMAppleSoftwareUpdateRequired(*settings), nil + } + + return nil, nil +} + func (svc *Service) mdmPushCertTopic(ctx context.Context) (string, error) { assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetAPNSCert, diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 545de31a0a..4c959a7649 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -24,6 +24,7 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/license" @@ -3371,3 +3372,80 @@ func TestUnmarshalAppList(t *testing.T) { require.NoError(t, err) assert.ElementsMatch(t, expectedSoftware, software) } + +func TestCheckMDMAppleEnrollmentWithMinimumOSVersion(t *testing.T) { + svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium}) + + ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serialNumber string) (*fleet.AppleOSUpdateSettings, error) { + return &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("14.2"), + }, nil + } + + testCases := []struct { + name string + deviceOSVersion string + mdmCanRequestSoftwareUpdate bool + wantUpdateRequired string + }{ + { + name: "OS version is greater than minimum", + deviceOSVersion: "15.0", + mdmCanRequestSoftwareUpdate: true, + wantUpdateRequired: "", + }, + { + name: "OS version is equal to minimum", + deviceOSVersion: "14.2", + mdmCanRequestSoftwareUpdate: true, + wantUpdateRequired: "", + }, + { + name: "OS version is less than minimum", + deviceOSVersion: "14.0.2", + mdmCanRequestSoftwareUpdate: true, + wantUpdateRequired: "14.2", + }, + { + name: "OS version is less than minimum but MDM cannot request software update", + deviceOSVersion: "14.0.2", + mdmCanRequestSoftwareUpdate: false, + wantUpdateRequired: "", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, &fleet.MDMAppleMachineInfo{OSVersion: tt.deviceOSVersion, MDMCanRequestSoftwareUpdate: tt.mdmCanRequestSoftwareUpdate}) + require.NoError(t, err) + if tt.wantUpdateRequired == "" { + require.Nil(t, sur) + } else { + require.Equal(t, &fleet.MDMAppleSoftwareUpdateRequired{ + Code: fleet.MDMAppleSoftwareUpdateRequiredCode, + Details: fleet.MDMAppleSoftwareUpdateRequiredDetails{ + OSVersion: tt.wantUpdateRequired, + }, + }, sur) + } + }) + } + + t.Run("error getting OS update settings", func(t *testing.T) { + ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serialNumber string) (*fleet.AppleOSUpdateSettings, error) { + return nil, newNotFoundError() + } + + sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, &fleet.MDMAppleMachineInfo{OSVersion: "14.0.2", MDMCanRequestSoftwareUpdate: true}) + require.NoError(t, err) + require.Nil(t, sur) + + ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serialNumber string) (*fleet.AppleOSUpdateSettings, error) { + return nil, errors.New("error") + } + + sur, err = svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, &fleet.MDMAppleMachineInfo{OSVersion: "14.0.2", MDMCanRequestSoftwareUpdate: true}) + require.Error(t, err) + require.Nil(t, sur) + }) +}