From 05818902cd66cb8dcc6f2dd9973efcfefaabef20 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Tue, 10 Sep 2024 16:52:17 -0300 Subject: [PATCH] implement OTA enrollment (#21942) for #21019 # 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/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) - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- changes/21019-ota-enrollment | 1 + cmd/fleet/serve_test.go | 2 +- .../IosIpadosPanel/IosIpadosPanel.tsx | 4 +- go.mod | 3 +- go.sum | 2 + pkg/mdm/mdmtest/apple.go | 198 ++++++++++-- server/datastore/mysql/apple_mdm.go | 291 +++++++++++------- server/datastore/mysql/apple_mdm_test.go | 64 ++++ server/datastore/mysql/testing_utils.go | 2 +- server/fleet/apple_mdm_test.go | 2 +- server/fleet/datastore.go | 4 + server/fleet/service.go | 26 ++ server/mdm/apple/AppleIphoneDeviceCA.cer | Bin 0 -> 877 bytes server/mdm/apple/apple_mdm.go | 56 ++++ server/mdm/apple/commander_test.go | 2 +- server/mdm/apple/deviceinfo.go | 84 +++-- server/mdm/apple/gdmf/api.go | 3 +- server/mdm/apple/gdmf/api_test.go | 26 +- server/mdm/apple/mobileconfig/profiles.go | 2 +- server/mdm/assets/assets_test.go | 2 +- server/mdm/mdm.go | 2 +- server/mdm/microsoft/microsoft_mdm.go | 2 +- server/mdm/microsoft/wstep.go | 2 +- server/mdm/nanodep/tokenpki/parse.go | 2 +- server/mdm/nanomdm/cryptoutil/cryptoutil.go | 2 +- .../mdm/nanomdm/cryptoutil/cryptoutil_test.go | 2 +- server/mdm/scep/scep/scep.go | 2 +- server/mock/datastore_mock.go | 12 + server/service/apple_mdm.go | 185 ++++++++++- server/service/apple_mdm_test.go | 2 +- server/service/handler.go | 1 + server/service/hosts_test.go | 2 +- .../service/integration_mdm_lifecycle_test.go | 2 +- .../service/integration_mdm_profiles_test.go | 2 +- server/service/integration_mdm_test.go | 144 ++++++++- 35 files changed, 936 insertions(+), 202 deletions(-) create mode 100644 changes/21019-ota-enrollment create mode 100644 server/mdm/apple/AppleIphoneDeviceCA.cer diff --git a/changes/21019-ota-enrollment b/changes/21019-ota-enrollment new file mode 100644 index 0000000000..b43db060a7 --- /dev/null +++ b/changes/21019-ota-enrollment @@ -0,0 +1 @@ +* Implement protocol support for OTA enrollment and automatic team assignment for hosts. diff --git a/cmd/fleet/serve_test.go b/cmd/fleet/serve_test.go index 675f1242aa..e472566f3e 100644 --- a/cmd/fleet/serve_test.go +++ b/cmd/fleet/serve_test.go @@ -30,9 +30,9 @@ import ( "github.com/go-kit/log" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) // safeStore is a wrapper around mock.Store to allow for concurrent calling to diff --git a/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx index 40d991e2f8..7cfba0f4d2 100644 --- a/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx +++ b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx @@ -6,7 +6,9 @@ import { AppContext } from "context/app"; import InputField from "components/forms/fields/InputField"; const generateUrl = (serverUrl: string, enrollSecret: string) => { - return `${serverUrl}/enroll?enroll_secret=${enrollSecret}`; + return `${serverUrl}/enroll?enroll_secret=${encodeURIComponent( + enrollSecret + )}`; }; const baseClass = "ios-ipados-panel"; diff --git a/go.mod b/go.mod index d1458f2471..82d9ea538b 100644 --- a/go.mod +++ b/go.mod @@ -95,6 +95,7 @@ require ( github.com/sethvargo/go-password v0.3.0 github.com/shirou/gopsutil/v3 v3.24.3 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 + github.com/smallstep/pkcs7 v0.0.0-20240723090913-5e2c6a136dfa github.com/spf13/cast v1.4.1 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.10.0 @@ -110,7 +111,6 @@ require ( go.elastic.co/apm/module/apmsql/v2 v2.4.3 go.elastic.co/apm/v2 v2.4.3 go.etcd.io/bbolt v1.3.9 - go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.44.0 go.opentelemetry.io/otel v1.28.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 @@ -308,6 +308,7 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.elastic.co/apm/module/apmhttp/v2 v2.3.0 // indirect go.elastic.co/fastjson v1.1.0 // indirect + go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect diff --git a/go.sum b/go.sum index 239c87b7d1..68586039a4 100644 --- a/go.sum +++ b/go.sum @@ -1056,6 +1056,8 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/slack-go/slack v0.9.4 h1:C+FC3zLxLxUTQjDy2RZeMHYon005zsCROiZNWVo+opQ= github.com/slack-go/slack v0.9.4/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= +github.com/smallstep/pkcs7 v0.0.0-20240723090913-5e2c6a136dfa h1:FtxzVccOwaK+bK4bnWBPGua0FpCOhrVyeo6Fy9nxdlo= +github.com/smallstep/pkcs7 v0.0.0-20240723090913-5e2c6a136dfa/go.mod h1:SoUAr/4M46rZ3WaLstHxGhLEgoYIDRqxQEXLOmOEB0Y= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= diff --git a/pkg/mdm/mdmtest/apple.go b/pkg/mdm/mdmtest/apple.go index 326d40be60..f754b85620 100644 --- a/pkg/mdm/mdmtest/apple.go +++ b/pkg/mdm/mdmtest/apple.go @@ -33,7 +33,7 @@ import ( kitlog "github.com/go-kit/log" "github.com/google/uuid" "github.com/groob/plist" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // TestAppleMDMClient simulates a macOS MDM client. @@ -65,6 +65,13 @@ type TestAppleMDMClient struct { // fetchEnrollmentProfileFromDEP indicates whether this simulated device will fetch // the enrollment profile from Fleet as if it were a device running the DEP flow. fetchEnrollmentProfileFromDEP bool + + // fetchEnrollmentProfileFromOTA indicates whether this simulated device will fetch + // the enrollment profile from Fleet as if it were a device running the OTA flow. + fetchEnrollmentProfileFromOTA bool + // otaEnrollSecret is the team enroll secret to be used during the OTA flow. + otaEnrollSecret string + // desktopURLToken is the token used to fetch the enrollment profile // from Fleet as if it were a device running the DEP flow. depURLToken string @@ -151,6 +158,24 @@ func NewTestMDMClientAppleDirect(enrollInfo AppleEnrollInfo, model string, opts return &c } +// NewTestMDMClientAppleOTA will create a simulated device that will fetch +// enrollment profile from Fleet as if it were a device running the Over The +// Air (OTA) flow. +func NewTestMDMClientAppleOTA(serverURL, enrollSecret, model string, opts ...TestMDMAppleClientOption) *TestAppleMDMClient { + c := TestAppleMDMClient{ + UUID: strings.ToUpper(uuid.New().String()), + SerialNumber: RandSerialNumber(), + Model: model, + fetchEnrollmentProfileFromOTA: true, + fleetServerURL: serverURL, + otaEnrollSecret: enrollSecret, + } + for _, fn := range opts { + fn(&c) + } + return &c +} + func (c *TestAppleMDMClient) SetDesktopToken(tok string) { c.desktopURLToken = tok } @@ -170,6 +195,10 @@ func (c *TestAppleMDMClient) Enroll() error { if err := c.fetchEnrollmentProfileFromDEPURL(); err != nil { return fmt.Errorf("get enrollment profile from DEP URL: %w", err) } + case c.fetchEnrollmentProfileFromOTA: + if err := c.fetchEnrollmentProfileFromOTAURL(); err != nil { + return fmt.Errorf("get enrollment profile from OTA URL: %w", err) + } default: if c.EnrollInfo.SCEPURL == "" || c.EnrollInfo.MDMURL == "" || c.EnrollInfo.SCEPChallenge == "" { return fmt.Errorf("missing info needed to perform enrollment: %+v", c.EnrollInfo) @@ -199,6 +228,120 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDEPURL() error { ) } +func (c *TestAppleMDMClient) fetchEnrollmentProfileFromOTAURL() error { + rawDeviceInfo := []byte(fmt.Sprintf(` + + + + PRODUCT + %s + SERIAL + %s + UDID + %s + VERSION + 22A5316k + +`, c.Model, c.SerialNumber, c.UUID)) + + do := func(cert *x509.Certificate, key *rsa.PrivateKey) ([]byte, error) { + signedData, err := pkcs7.NewSignedData(rawDeviceInfo) + if err != nil { + return nil, fmt.Errorf("create signed data: %w", err) + } + err = signedData.AddSigner(cert, key, pkcs7.SignerInfoConfig{}) + if err != nil { + return nil, fmt.Errorf("add signer: %w", err) + } + sig, err := signedData.Finish() + if err != nil { + return nil, fmt.Errorf("finish signing: %w", err) + } + + request, err := http.NewRequest( + "POST", + c.fleetServerURL+"/api/latest/fleet/ota_enrollment?enroll_secret="+c.otaEnrollSecret, + bytes.NewReader(sig), + ) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + // #nosec (this client is used for testing only) + cc := fleethttp.NewClient(fleethttp.WithTLSClientConfig(&tls.Config{ + InsecureSkipVerify: true, + })) + response, err := cc.Do(request) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + return body, nil + } + + // TODO(roberto 09-10-2024): the first request in the OTA flow must be + // signed using a keypair that has a valid Apple certificate as root. I + // believe this could be done with a little bit of reverse + // engineering/cleverness but for now, we're signing the request with + // our mock certs and setting this env var to skip the verification. + os.Setenv("FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY", "1") + mockedCert, mockedKey, err := apple_mdm.NewSCEPCACertKey() + if err != nil { + return fmt.Errorf("creating mock certificates: %w", err) + } + body, err := do(mockedCert, mockedKey) + if err != nil { + return fmt.Errorf("first OTA request: %w", err) + } + os.Unsetenv("FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY") + + var scepInfo struct { + PayloadContent []struct { + PayloadContent struct { + Challenge string `plist:"Challenge"` + URL string `plist:"URL"` + } `plist:"PayloadContent"` + } `plist:"PayloadContent"` + } + + err = plist.Unmarshal(body, &scepInfo) + if err != nil { + return fmt.Errorf("unmarshaling SCEP response: %w", err) + } + + tmpCert, tmpKey, err := c.doSCEP(scepInfo.PayloadContent[0].PayloadContent.URL, scepInfo.PayloadContent[0].PayloadContent.Challenge) + if err != nil { + return fmt.Errorf("get SCEP certificate for OTA: %w", err) + } + + body, err = do(tmpCert, tmpKey) + if err != nil { + return fmt.Errorf("seconde OTA request: %w", err) + } + p7, err := pkcs7.Parse(body) + if err != nil { + return fmt.Errorf("enrollment profile is not XML nor PKCS7 parseable: %w", err) + } + err = p7.Verify() + if err != nil { + return fmt.Errorf("verifying enrollment profile: %w", err) + } + enrollInfo, err := ParseEnrollmentProfile(p7.Content) + if err != nil { + return fmt.Errorf("parse OTA SCEP profile: %w", err) + } + c.EnrollInfo = *enrollInfo + return nil +} + func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string) error { request, err := http.NewRequest("GET", c.fleetServerURL+path, nil) if err != nil { @@ -212,6 +355,7 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string) error { if err != nil { return fmt.Errorf("send request: %w", err) } + defer response.Body.Close() if response.StatusCode != http.StatusOK { return fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status) } @@ -247,8 +391,7 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string) error { return nil } -// SCEPEnroll runs the SCEP enroll protocol for the simulated device. -func (c *TestAppleMDMClient) SCEPEnroll() error { +func (c *TestAppleMDMClient) doSCEP(url, challenge string) (*x509.Certificate, *rsa.PrivateKey, error) { ctx := context.Background() var logger log.Logger @@ -257,25 +400,25 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { } else { logger = kitlog.NewNopLogger() } - client, err := newSCEPClient(c.EnrollInfo.SCEPURL, logger) + client, err := newSCEPClient(url, logger) if err != nil { - return fmt.Errorf("scep client: %w", err) + return nil, nil, fmt.Errorf("scep client: %w", err) } // (1). Get the CA certificate from the SCEP server. resp, _, err := client.GetCACert(ctx, "") if err != nil { - return fmt.Errorf("get CA cert: %w", err) + return nil, nil, fmt.Errorf("get CA cert: %w", err) } caCert, err := x509.ParseCertificates(resp) if err != nil { - return fmt.Errorf("parse CA cert: %w", err) + return nil, nil, fmt.Errorf("parse CA cert: %w", err) } // (2). Generate RSA key pair. devicePrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { - return fmt.Errorf("generate RSA private key: %w", err) + return nil, nil, fmt.Errorf("generate RSA private key: %w", err) } // (3). Generate CSR. @@ -288,15 +431,15 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { }, SignatureAlgorithm: x509.SHA256WithRSA, }, - ChallengePassword: c.EnrollInfo.SCEPChallenge, + ChallengePassword: challenge, } csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, devicePrivateKey) if err != nil { - return fmt.Errorf("create CSR: %w", err) + return nil, nil, fmt.Errorf("create CSR: %w", err) } csr, err := x509.ParseCertificateRequest(csrDerBytes) if err != nil { - return fmt.Errorf("parse CSR: %w", err) + return nil, nil, fmt.Errorf("parse CSR: %w", err) } // (4). SCEP requires a certificate for client authentication. We generate a new one @@ -312,7 +455,7 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) certSerialNumber, err := rand.Int(rand.Reader, serialNumberLimit) if err != nil { - return fmt.Errorf("generate cert serial number: %w", err) + return nil, nil, fmt.Errorf("generate cert serial number: %w", err) } deviceCertificateTemplate := x509.Certificate{ SerialNumber: certSerialNumber, @@ -334,11 +477,11 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { devicePrivateKey, ) if err != nil { - return fmt.Errorf("create device certificate: %w", err) + return nil, nil, fmt.Errorf("create device certificate: %w", err) } deviceCertificateForRequest, err := x509.ParseCertificate(deviceCertificateDerBytes) if err != nil { - return fmt.Errorf("parse device certificate: %w", err) + return nil, nil, fmt.Errorf("parse device certificate: %w", err) } // (5). Send the PKCSReq message to the SCEP server. @@ -353,31 +496,40 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { } msg, err := scep.NewCSRRequest(csr, pkiMsgReq, scep.WithLogger(logger)) if err != nil { - return fmt.Errorf("create CSR request: %w", err) + return nil, nil, fmt.Errorf("create CSR request: %w", err) } respBytes, err := client.PKIOperation(ctx, msg.Raw) if err != nil { - return fmt.Errorf("do CSR request: %w", err) + return nil, nil, fmt.Errorf("do CSR request: %w", err) } pkiMsgResp, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(logger), scep.WithCACerts(msg.Recipients)) if err != nil { - return fmt.Errorf("parse PKIMessage response: %w", err) + return nil, nil, fmt.Errorf("parse PKIMessage response: %w", err) } if pkiMsgResp.PKIStatus != scep.SUCCESS { - return fmt.Errorf("PKIMessage CSR request failed with code: %s, fail info: %s", pkiMsgResp.PKIStatus, pkiMsgResp.FailInfo) + return nil, nil, fmt.Errorf("PKIMessage CSR request failed with code: %s, fail info: %s", pkiMsgResp.PKIStatus, pkiMsgResp.FailInfo) } if err := pkiMsgResp.DecryptPKIEnvelope(deviceCertificateForRequest, devicePrivateKey); err != nil { - return fmt.Errorf("decrypt PKI envelope: %w", err) + return nil, nil, fmt.Errorf("decrypt PKI envelope: %w", err) } - // (6). Finally, set the signed certificate returned from the server as the device certificate and key. - c.scepCert = pkiMsgResp.CertRepMessage.Certificate - c.scepKey = devicePrivateKey - if c.debug { fmt.Println("SCEP enrollment successful") } + // (6). return the signed certificate returned from the server as the device certificate and key. + return pkiMsgResp.CertRepMessage.Certificate, devicePrivateKey, nil +} + +// SCEPEnroll runs the SCEP enroll protocol for the simulated device. +func (c *TestAppleMDMClient) SCEPEnroll() error { + cert, key, err := c.doSCEP(c.EnrollInfo.SCEPURL, c.EnrollInfo.SCEPChallenge) + if err != nil { + return err + } + + c.scepCert = cert + c.scepKey = key return nil } diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index e93c2fe9c0..7943068687 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -896,61 +896,35 @@ func insertMDMAppleHostDB( return nil } -type hostWithEnrolled struct { - fleet.Host - Enrolled *bool `db:"enrolled"` +// hostToCreateFromMDM defines a common set of parameters required to create +// host records without a pre-existing osquery enrollment from MDM flows like +// ADE ingestion or OTA enrollments +type hostToCreateFromMDM struct { + // HardwareSerial should match the value for hosts.hardware_serial + HardwareSerial string + // HardwareModel should match the value for hosts.hardware_model + HardwareModel string + // PlatformHint is used to determine hosts.platform, if it: + // + // - contains "iphone" the platform is "ios" + // - contains "ipad" the platform is "ipados" + // - otherwise the platform is "darwin" + PlatformHint string } -func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync( +func createHostFromMDMDB( ctx context.Context, - devices []godep.Device, - abmTokenID uint, - macOSTeam, iosTeam, ipadTeam *fleet.Team, -) (createdCount int64, err error) { - if len(devices) < 1 { - level.Debug(ds.logger).Log("msg", "ingesting devices from DEP received < 1 device, skipping", "len(devices)", len(devices)) - return 0, nil - } + tx sqlx.ExtContext, + logger log.Logger, + devices []hostToCreateFromMDM, + macOSTeam, iosTeam, ipadTeam *uint, +) (int64, []fleet.Host, error) { + // NOTE: order of arguments for teams is important, see statement. + args := []any{iosTeam, ipadTeam, macOSTeam} + us, unionArgs := unionSelectDevices(devices) + args = append(args, unionArgs...) - appCfg, err := ds.AppConfig(ctx) - if err != nil { - return 0, ctxerr.Wrap(ctx, err, "ingest mdm apple host get app config") - } - - var args []any - teams := []*fleet.Team{iosTeam, ipadTeam, macOSTeam} - for _, team := range teams { - if team == nil { - args = append(args, nil) - continue - } - - exists, err := ds.TeamExists(ctx, team.ID) - if err != nil { - return 0, ctxerr.Wrap(ctx, err, "ingest mdm apple host get team by name") - } - - if exists { - args = append(args, team.ID) - continue - } - - // If the team doesn't exist, we still ingest the device, but it won't - // belong to any team. - level.Debug(ds.logger).Log( - "msg", - "ingesting devices from ABM: unable to find default team assigned in config, the devices won't be assigned to a team", - "team_id", - team, - ) - args = append(args, nil) - } - - err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - us, unionArgs := unionSelectDevices(devices) - args = append(args, unionArgs...) - - stmt := fmt.Sprintf(` + stmt := fmt.Sprintf(` INSERT INTO hosts ( hardware_serial, hardware_model, @@ -980,29 +954,28 @@ func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync( h.id IS NULL GROUP BY us.hardware_serial, us.platform)`, - us, - ) + us, + ) - res, err := tx.ExecContext(ctx, stmt, args...) - if err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple hosts from dep sync insert") - } + res, err := tx.ExecContext(ctx, stmt, args...) + if err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "inserting new host in MDM ingestion") + } - n, err := res.RowsAffected() - if err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple hosts from dep sync rows affected") - } - createdCount = n + n, _ := res.RowsAffected() + // get new host ids + args = []any{} + parts := []string{} + for _, d := range devices { + args = append(args, d.HardwareSerial) + parts = append(parts, "?") + } - // get new host ids - args = []interface{}{} - parts := []string{} - for _, d := range devices { - args = append(args, d.SerialNumber) - parts = append(parts, "?") - } - var hostsWithEnrolled []hostWithEnrolled - err = sqlx.SelectContext(ctx, tx, &hostsWithEnrolled, fmt.Sprintf(` + var hostsWithEnrolled []struct { + fleet.Host + Enrolled *bool `db:"enrolled"` + } + err = sqlx.SelectContext(ctx, tx, &hostsWithEnrolled, fmt.Sprintf(` SELECT h.id, h.platform, @@ -1012,47 +985,135 @@ func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync( FROM hosts h LEFT JOIN host_mdm hmdm ON hmdm.host_id = h.id WHERE h.hardware_serial IN(%s)`, - strings.Join(parts, ",")), - args...) - if err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host get host ids") + strings.Join(parts, ",")), + args...) + if err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host get host ids") + } + + var hosts []fleet.Host + var unmanagedHostIDs []uint + for _, h := range hostsWithEnrolled { + hosts = append(hosts, h.Host) + if h.Enrolled == nil || !*h.Enrolled { + unmanagedHostIDs = append(unmanagedHostIDs, h.ID) + } + } + + if err := upsertMDMAppleHostDisplayNamesDB(ctx, tx, hosts...); err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert display names") + } + + if err := upsertMDMAppleHostLabelMembershipDB(ctx, tx, logger, hosts...); err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert label membership") + } + + appCfg, err := appConfigDB(ctx, tx) + if err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host get app config") + } + + // only upsert MDM info for hosts that are unmanaged. This + // prevents us from overriding valuable info with potentially + // incorrect data. For example: if a host is enrolled in a + // third-party MDM, but gets assigned in ABM to Fleet (during + // migration) we'll get an 'added' event. In that case, we + // expect that MDM info will be updated in due time as we ingest + // future osquery data from the host + if err := upsertMDMAppleHostMDMInfoDB( + ctx, + tx, + appCfg.ServerSettings, + true, + unmanagedHostIDs..., + ); err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info") + } + + return n, hosts, nil +} + +func (ds *Datastore) IngestMDMAppleDeviceFromOTAEnrollment( + ctx context.Context, + teamID *uint, + deviceInfo fleet.MDMAppleMachineInfo, +) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + toInsert := []hostToCreateFromMDM{ + { + HardwareSerial: deviceInfo.Serial, + PlatformHint: deviceInfo.Product, + HardwareModel: deviceInfo.Product, + }, + } + _, _, err := createHostFromMDMDB(ctx, tx, ds.logger, toInsert, teamID, teamID, teamID) + return ctxerr.Wrap(ctx, err, "creating host from OTA enrollment") + }) +} + +func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync( + ctx context.Context, + devices []godep.Device, + abmTokenID uint, + macOSTeam, iosTeam, ipadTeam *fleet.Team, +) (createdCount int64, err error) { + if len(devices) < 1 { + level.Debug(ds.logger).Log("msg", "ingesting devices from DEP received < 1 device, skipping", "len(devices)", len(devices)) + return 0, nil + } + + var teamIDs []*uint + for _, team := range []*fleet.Team{macOSTeam, iosTeam, ipadTeam} { + if team == nil { + teamIDs = append(teamIDs, nil) + continue } - var hosts []fleet.Host - var unmanagedHostIDs []uint - for _, h := range hostsWithEnrolled { - hosts = append(hosts, h.Host) - if h.Enrolled == nil || !*h.Enrolled { - unmanagedHostIDs = append(unmanagedHostIDs, h.ID) + exists, err := ds.TeamExists(ctx, team.ID) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "ingest mdm apple host get team by name") + } + + if exists { + teamIDs = append(teamIDs, &team.ID) + continue + } + + // If the team doesn't exist, we still ingest the device, but it won't + // belong to any team. + level.Debug(ds.logger).Log( + "msg", + "ingesting devices from ABM: unable to find default team assigned in config, the devices won't be assigned to a team", + "team_id", + team, + ) + teamIDs = append(teamIDs, nil) + } + + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + htc := make([]hostToCreateFromMDM, len(devices)) + for i, d := range devices { + htc[i] = hostToCreateFromMDM{ + HardwareSerial: d.SerialNumber, + HardwareModel: d.Model, + PlatformHint: d.DeviceFamily, } } - if err := upsertMDMAppleHostDisplayNamesDB(ctx, tx, hosts...); err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert display names") - } - - if err := upsertMDMAppleHostLabelMembershipDB(ctx, tx, ds.logger, hosts...); err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert label membership") - } - if err := upsertHostDEPAssignmentsDB(ctx, tx, hosts, abmTokenID); err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert DEP assignments") - } - - // only upsert MDM info for hosts that are unmanaged. This - // prevents us from overriding valuable info with potentially - // incorrect data. For example: if a host is enrolled in a - // third-party MDM, but gets assigned in ABM to Fleet (during - // migration) we'll get an 'added' event. In that case, we - // expect that MDM info will be updated in due time as we ingest - // future osquery data from the host - if err := upsertMDMAppleHostMDMInfoDB( + n, hosts, err := createHostFromMDMDB( ctx, tx, - appCfg.ServerSettings, - true, - unmanagedHostIDs..., - ); err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info") + ds.logger, + htc, + teamIDs[0], teamIDs[1], teamIDs[2], + ) + if err != nil { + return err + } + createdCount = n + + if err := upsertHostDEPAssignmentsDB(ctx, tx, hosts, abmTokenID); err != nil { + return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert DEP assignments") } return nil @@ -1310,22 +1371,24 @@ func (ds *Datastore) MDMTurnOff(ctx context.Context, uuid string) error { }) } -func unionSelectDevices(devices []godep.Device) (stmt string, args []interface{}) { +func unionSelectDevices(devices []hostToCreateFromMDM) (stmt string, args []interface{}) { for i, d := range devices { if i == 0 { stmt = "SELECT ? hardware_serial, ? hardware_model, ? platform" } else { stmt += " UNION SELECT ?, ?, ?" } - // Map Apple's device family to Fleet's hosts.platform field. - platform := "darwin" - switch d.DeviceFamily { - case "iPhone": - platform = "ios" - case "iPad": - platform = "ipados" + + // map the platform hint to Fleet's hosts.platform field. + normalizedHint := strings.ToLower(d.PlatformHint) + platform := string(fleet.MacOSPlatform) + switch { + case strings.Contains(normalizedHint, "iphone"): + platform = string(fleet.IOSPlatform) + case strings.Contains(normalizedHint, "ipad"): + platform = string(fleet.IPadOSPlatform) } - args = append(args, d.SerialNumber, d.Model, platform) + args = append(args, d.HardwareSerial, d.HardwareModel, platform) } return stmt, args diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index aba31a3c8e..3090bdb04d 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -88,6 +88,7 @@ func TestMDMApple(t *testing.T) { {"ABMTokensTermsExpired", testMDMAppleABMTokensTermsExpired}, {"TestMDMGetABMTokenOrgNamesAssociatedWithTeam", testMDMGetABMTokenOrgNamesAssociatedWithTeam}, {"HostMDMCommands", testHostMDMCommands}, + {"IngestMDMAppleDeviceFromOTAEnrollment", testIngestMDMAppleDeviceFromOTAEnrollment}, } for _, c := range cases { @@ -6793,6 +6794,7 @@ func testMDMGetABMTokenOrgNamesAssociatedWithTeam(t *testing.T, ds *Datastore) { require.Len(t, orgNames, 1) require.Equal(t, orgNames[0], "org3") } + func testHostMDMCommands(t *testing.T, ds *Datastore) { ctx := context.Background() @@ -6864,6 +6866,68 @@ func testHostMDMCommands(t *testing.T, ds *Datastore) { assert.ElementsMatch(t, hostCommands[1:], commands) } +func testIngestMDMAppleDeviceFromOTAEnrollment(t *testing.T, ds *Datastore) { + ctx := context.Background() + createBuiltinLabels(t, ds) + + for i := 0; i < 10; i++ { + _, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: fmt.Sprintf("hostname_%d", i), + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-time.Duration(i) * time.Minute), + OsqueryHostID: ptr.String(fmt.Sprintf("osquery-host-id_%d", i)), + NodeKey: ptr.String(fmt.Sprintf("node-key_%d", i)), + UUID: fmt.Sprintf("uuid_%d", i), + HardwareSerial: fmt.Sprintf("serial_%d", i), + }) + require.NoError(t, err) + } + + hosts := listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 10) + wantSerials := []string{} + for _, h := range hosts { + wantSerials = append(wantSerials, h.HardwareSerial) + } + + // mock results incoming from OTA enrollments + otaDevices := []fleet.MDMAppleMachineInfo{ + {Serial: "abc", Product: "MacBook Pro"}, + {Serial: "abc", Product: "MacBook Pro"}, + {Serial: hosts[0].HardwareSerial, Product: "MacBook Pro"}, + {Serial: "ijk", Product: "iPad13,16"}, + {Serial: "tuv", Product: "iPhone14,6"}, + {Serial: hosts[1].HardwareSerial, Product: "MacBook Pro"}, + {Serial: "xyz", Product: "MacBook Pro"}, + {Serial: "xyz", Product: "MacBook Pro"}, + {Serial: "xyz", Product: "MacBook Pro"}, + } + wantSerials = append(wantSerials, "abc", "xyz", "ijk", "tuv") + + for _, d := range otaDevices { + err := ds.IngestMDMAppleDeviceFromOTAEnrollment(ctx, nil, d) + require.NoError(t, err) + } + + hosts = listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, len(wantSerials)) + gotSerials := []string{} + for _, h := range hosts { + gotSerials = append(gotSerials, h.HardwareSerial) + + switch h.HardwareSerial { + case "abc", "xyz": + checkMDMHostRelatedTables(t, ds, h.ID, h.HardwareSerial, "MacBook Pro") + case "ijk": + checkMDMHostRelatedTables(t, ds, h.ID, h.HardwareSerial, "iPad13,16") + case "tuv": + checkMDMHostRelatedTables(t, ds, h.ID, h.HardwareSerial, "iPhone14,6") + + } + } + require.ElementsMatch(t, wantSerials, gotSerials) +} + func TestGetMDMAppleOSUpdatesSettingsByHostSerial(t *testing.T) { ds := CreateMySQLDS(t) defer ds.Close() diff --git a/server/datastore/mysql/testing_utils.go b/server/datastore/mysql/testing_utils.go index 99b5989194..51b7491c0c 100644 --- a/server/datastore/mysql/testing_utils.go +++ b/server/datastore/mysql/testing_utils.go @@ -33,8 +33,8 @@ import ( "github.com/go-kit/log" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) const ( diff --git a/server/fleet/apple_mdm_test.go b/server/fleet/apple_mdm_test.go index f31796d4a4..240c37c9d7 100644 --- a/server/fleet/apple_mdm_test.go +++ b/server/fleet/apple_mdm_test.go @@ -18,9 +18,9 @@ import ( "github.com/fleetdm/fleet/v4/server/ptr" "github.com/google/go-cmp/cmp" "github.com/google/uuid" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) func TestMDMAppleConfigProfile(t *testing.T) { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 2689b1114c..1c767c5148 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1112,6 +1112,10 @@ type Datastore interface { // not already enrolled in Fleet. It returns the number of hosts created, and an error. IngestMDMAppleDevicesFromDEPSync(ctx context.Context, devices []godep.Device, abmTokenID uint, macOSTeam, iosTeam, ipadTeam *Team) (int64, error) + // IngestMDMAppleDeviceFromOTAEnrollment creates new host records for + // MDM-enrolled devices via OTA that are not already enrolled in Fleet. + IngestMDMAppleDeviceFromOTAEnrollment(ctx context.Context, teamID *uint, deviceInfo MDMAppleMachineInfo) error + // MDMAppleUpsertHost creates or matches a Fleet host record for an // MDM-enrolled device. MDMAppleUpsertHost(ctx context.Context, mdmHost *Host) error diff --git a/server/fleet/service.go b/server/fleet/service.go index ce3aa904a9..a7971cff02 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -655,6 +655,32 @@ type Service interface { AddAppStoreApp(ctx context.Context, teamID *uint, appTeam VPPAppTeam) error + // MDMAppleProcessOTAEnrollment handles OTA enrollment requests. + // + // Per the [spec][1] OTA enrollment is composed of two phases, each + // phase is a request sent by the host to the same endpoint, but it + // must be handled differently depending on the signatures of the + // request body: + // + // 1. First request has a certificate signed by Apple's CA as the root + // certificate. The server must return a SCEP payload that the device + // will use to get a keypair. Note that this keypair is _different_ + // from the "SCEP identity certificate" that will be generated during + // MDM enrollment, and only used for OTA. + // + // 2. Second request has the SCEP certificate generated in `1` as the + // root certificate, the server responds with a "classic" enrollment + // profile and the device starts the regular enrollment process from there. + // + // The extra steps allows us to grab device information like the serial + // number and hardware uuid to perform operations before the host even + // enrolls in MDM. Currently, this method creates a host records and + // assigns a pre-defined team (based on the enrollSecret provided) to + // the host. + // + // [1]: https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/iPhoneOTAConfiguration/Introduction/Introduction.html#//apple_ref/doc/uid/TP40009505-CH1-SW1 + MDMAppleProcessOTAEnrollment(ctx context.Context, certificates []*x509.Certificate, rootSigner *x509.Certificate, enrollSecret string, deviceInfo MDMAppleMachineInfo) ([]byte, error) + // ///////////////////////////////////////////////////////////////////////////// // Vulnerabilities diff --git a/server/mdm/apple/AppleIphoneDeviceCA.cer b/server/mdm/apple/AppleIphoneDeviceCA.cer new file mode 100644 index 0000000000000000000000000000000000000000..fac79ff89daf7520aec9adafa728f34524663bac GIT binary patch literal 877 zcmXqLV$L*ZVhUWq%*4pV#K>sC%f_kI=F#?@mywZ`mBFCWklTQhjX9KsO_(V(*ihI& z5X9jU=5j13$VpZ3%uCiYR5MTk333a|LIj;ti%K%nGLsWaGV}8k97{_w@{2M{Dh+iF zv_a~ag;gNxG6OR5^HLRXC^wK3=QS`lFflYUGBPzWF^dxCH8eru8blG{G*JU#h|_qG ztu&N3kcGHJ3|UygCABOw8R#HKgU0#D0l~=1z}(o&V9?mf)Y!=Iaf;W;SG%Gl64stx zAfBAF>3$)0w4`irz#?(uHx(yO+56^Cp*&GK!p*$$pii>{Vt!=Tik#t)~L&(Zc# z+n{Ry(r8sRN6C~o2fZz1)BQMtrbKl|E{?fA`OAWTp{=`p+@{CnZ7O%wPmkHdEwMjx zXHMI9tk4Pgy<|F&2?c{}h#7+NNezUTswCD;Bu1+^4U^Kpv!CnMJ}ttU=_tiI$$yJFE~qgzumCBLXE8L;H_%<6y+E@~J)@+gz)D}gyu4g55tIh>lJj%*!K~y& z{mcSTlmc5(!)mX<-QC3UnUf-?~@TN|HN}3 zGyJ6A;Z`@XGP7;*e_r)(-S&WM=i7^1v&&zt7TOu37%JK4uIw(j@gna@nBbV-xyHaE?ZmLLTdhsPJs?pd^> z-Qu&M{<)SVnjvfKEEFY=&2|cXT(->Y?)KZ8yW@T?jI!Uopp0o#Lv-A`mxb(#7tUC| gyOUr0vNFiZ$F?%MnPa&Mf8KnJb)oluU9eFB0FE3n_5c6? literal 0 HcmV?d00001 diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index 4925202507..15b526b8c5 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -800,6 +800,62 @@ var funcMap = map[string]any{ "xml": mobileconfig.XMLEscapeString, } +var OTASCEPTemplate = template.Must(template.New("").Funcs(funcMap).Parse(` + + + + PayloadVersion + 1 + PayloadType + Configuration + PayloadIdentifier + Ignored + PayloadUUID + Ignored + PayloadContent + + + PayloadContent + + Key Type + RSA + Challenge + {{ .SCEPChallenge | xml }} + Key Usage + 5 + Keysize + 2048 + URL + {{ .SCEPURL }} + Subject + + + + O + Fleet + + + + + CN + Fleet Identity + + + + + PayloadIdentifier + com.fleetdm.fleet.mdm.apple.scep + PayloadType + com.apple.security.scep + PayloadUUID + BCA53F9D-5DD2-494D-98D3-0D0F20FF6BA1 + PayloadVersion + 1 + + + +`)) + // enrollmentProfileMobileconfigTemplate is the template Fleet uses to assemble a .mobileconfig enrollment profile to serve to devices. // // During a profile replacement, the system updates payloads with the same PayloadIdentifier and diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go index 0d21c66ab5..29138179f4 100644 --- a/server/mdm/apple/commander_test.go +++ b/server/mdm/apple/commander_test.go @@ -19,8 +19,8 @@ import ( "github.com/google/uuid" "github.com/groob/plist" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) func TestMDMAppleCommander(t *testing.T) { diff --git a/server/mdm/apple/deviceinfo.go b/server/mdm/apple/deviceinfo.go index ebed273690..2f53fe3862 100644 --- a/server/mdm/apple/deviceinfo.go +++ b/server/mdm/apple/deviceinfo.go @@ -36,9 +36,11 @@ import ( "encoding/base64" "errors" "fmt" + "os" + "github.com/fleetdm/fleet/v4/server/fleet" "github.com/groob/plist" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) const DeviceInfoHeader = "x-apple-aspen-deviceinfo" @@ -48,36 +50,26 @@ const DeviceInfoHeader = "x-apple-aspen-deviceinfo" //go:embed AppleIncRootCertificate.cer var appleRootCert []byte -func newAppleRootCert() *x509.Certificate { - cert, err := x509.ParseCertificate(appleRootCert) +// appleRootCA is Apple's Root CA parsed to an *x509.Certificate +var appleRootCA = newAppleCert(appleRootCert) + +// appleIphoneDeviceCA is the PEM data defined here converted to DER: +// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/iPhoneOTAConfiguration/profile-service/profile-service.html#//apple_ref/doc/uid/TP40009505-CH2-SW24 +// +//go:embed AppleIphoneDeviceCA.cer +var appleIphoneDeviceCACert []byte + +// appleIphoneDeviceCA is Apple's Iphone Device CA parsed to an *x509.Certificate +var appleIphoneDeviceCA = newAppleCert(appleIphoneDeviceCACert) + +func newAppleCert(crt []byte) *x509.Certificate { + cert, err := x509.ParseCertificate(crt) 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. @@ -142,7 +134,7 @@ outer: } // 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 +// of an x-apple-aspen-deviceinfo header. If successful, it returns the parsed *fleet.MDMAppleMachineInfo. 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. // @@ -152,7 +144,7 @@ outer: // // [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) { +func ParseDeviceinfo(b64 string, verify bool) (*fleet.MDMAppleMachineInfo, error) { buf, err := base64.StdEncoding.DecodeString(b64) if err != nil { return nil, fmt.Errorf("could not decode base64: %w", err) @@ -170,10 +162,46 @@ func ParseDeviceinfo(b64 string, verify bool) (*MachineInfo, error) { } } - info := new(MachineInfo) + info := new(fleet.MDMAppleMachineInfo) if err = plist.Unmarshal(p7.Content, info); err != nil { return nil, fmt.Errorf("could not decode plist: %w", err) } return info, nil } + +// VerifyFromAppleIphoneDeviceCA verifies a certificate was signed by Apple's iPhone Device CA. +// Manually verify the certificate since Go has deprecated verifying SHA1WithRSA x509 certificates. +// +// NOTE: most of this code was taken from micromdm. +func VerifyFromAppleIphoneDeviceCA(c *x509.Certificate) error { + if os.Getenv("FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY") == "1" { + return nil + } + + var hashType crypto.Hash + + switch c.SignatureAlgorithm { + case x509.SHA1WithRSA: + hashType = crypto.SHA1 + case x509.SHA256WithRSA: + hashType = crypto.SHA256 + default: + return fmt.Errorf("%w: %s", x509.ErrUnsupportedAlgorithm, c.SignatureAlgorithm) + } + + hasher := hashType.New() + hasher.Write(c.RawTBSCertificate) + hashed := hasher.Sum(nil) + + key, ok := appleIphoneDeviceCA.PublicKey.(*rsa.PublicKey) + if !ok { + panic("appleIphoneDeviceCA: invalid key type") + } + + if err := rsa.VerifyPKCS1v15(key, hashType, hashed, c.Signature); err != nil { + return fmt.Errorf("verifying signature: %w", err) + } + + return nil +} diff --git a/server/mdm/apple/gdmf/api.go b/server/mdm/apple/gdmf/api.go index 3fdea7ab51..ee8c671814 100644 --- a/server/mdm/apple/gdmf/api.go +++ b/server/mdm/apple/gdmf/api.go @@ -14,6 +14,7 @@ import ( "github.com/cenkalti/backoff" "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" ) @@ -69,7 +70,7 @@ type APIResponse struct { // asset is found, an error is returned. // [1]: http://gdmf.apple.com/v2/pmv // [2]: https://support.apple.com/guide/deployment/use-mdm-to-deploy-software-updates-depafd2fad80/web -func GetLatestOSVersion(device apple_mdm.MachineInfo) (*Asset, error) { +func GetLatestOSVersion(device fleet.MDMAppleMachineInfo) (*Asset, error) { r, err := GetAssetMetadata() if err != nil { return nil, fmt.Errorf("retrieving asset metadata: %w", err) diff --git a/server/mdm/apple/gdmf/api_test.go b/server/mdm/apple/gdmf/api_test.go index 8b78420fb0..14bc7d8061 100644 --- a/server/mdm/apple/gdmf/api_test.go +++ b/server/mdm/apple/gdmf/api_test.go @@ -6,7 +6,7 @@ import ( "os" "testing" - apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + "github.com/fleetdm/fleet/v4/server/fleet" "github.com/google/uuid" "github.com/stretchr/testify/require" ) @@ -27,7 +27,7 @@ func TestGetLatest(t *testing.T) { t.Setenv("FLEET_DEV_GDMF_URL", srv.URL) // test the function - d := apple_mdm.MachineInfo{ + d := fleet.MDMAppleMachineInfo{ MDMCanRequestSoftwareUpdate: true, OSVersion: "14.4.1", Product: "Mac15,7", @@ -53,14 +53,14 @@ func TestGetLatest(t *testing.T) { tests := []struct { name string - machineInfo apple_mdm.MachineInfo + machineInfo fleet.MDMAppleMachineInfo expectedVersion string expectedBuild string expectError bool }{ { name: "macOS matching software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "14.4.1", Product: "Mac15,7", Serial: "TESTSERIAL", @@ -76,7 +76,7 @@ func TestGetLatest(t *testing.T) { { // macOS generally relies on the SoftwareUpdateDeviceID field and not the Product field name: "macOS non-matching software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "14.4.1", Product: "Mac15,7", Serial: "TESTSERIAL", @@ -93,7 +93,7 @@ func TestGetLatest(t *testing.T) { // this should never happen in practice, but by default we still check macOS assets to // match the software update device ID name: "non-matching product but matching software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "14.4.1", Product: "INVALID", Serial: "TESTSERIAL", @@ -108,7 +108,7 @@ func TestGetLatest(t *testing.T) { }, { name: "non-matching product and software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "14.4.1", Product: "INVALID", Serial: "TESTSERIAL", @@ -125,7 +125,7 @@ func TestGetLatest(t *testing.T) { // missing other fields is not an error, this function always returns the latest // version and only depends on the Product and SoftwareUpdateDeviceID fields name: "missing other fields", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "", Product: "Mac15,7", SoftwareUpdateDeviceID: "J516sAP", @@ -136,7 +136,7 @@ func TestGetLatest(t *testing.T) { }, { name: "iphone matching product and software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "17.5.1", Product: "iPhone14,6", Serial: "TESTSERIAL", @@ -153,7 +153,7 @@ func TestGetLatest(t *testing.T) { // iOS generally relies on the Product field and not the SoftwareUpdateDeviceID field so // this won't error even though the SoftwareUpdateDeviceID is invalid name: "iphone non-matching software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "17.5.1", Product: "iPhone14,6", Serial: "TESTSERIAL", @@ -170,7 +170,7 @@ func TestGetLatest(t *testing.T) { // this should never happen in practice, but we'll still try to match iOS assets if the // software update device ID starts with "iPhone" or "iPad" name: "missing product but valid iphone software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "17.5.1", Product: "", Serial: "TESTSERIAL", @@ -187,7 +187,7 @@ func TestGetLatest(t *testing.T) { // we don't support other Apple products yet, so this should always error // because we we default to the macOS asset set and we won't find a matching asset there name: "unsupported product", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "8.8.1", Product: "Watch3,1", Serial: "TESTSERIAL", @@ -230,7 +230,7 @@ func TestRetries(t *testing.T) { os.Unsetenv("FLEET_DEV_GDMF_URL") }) - latest, err := GetLatestOSVersion(apple_mdm.MachineInfo{ + latest, err := GetLatestOSVersion(fleet.MDMAppleMachineInfo{ OSVersion: "14.4.1", Product: "Mac15,7", Serial: "TESTSERIAL", diff --git a/server/mdm/apple/mobileconfig/profiles.go b/server/mdm/apple/mobileconfig/profiles.go index b71d2db5ab..75634aca98 100644 --- a/server/mdm/apple/mobileconfig/profiles.go +++ b/server/mdm/apple/mobileconfig/profiles.go @@ -138,7 +138,7 @@ var OTAMobileConfigTemplate = template.Must(template.New("").Funcs(template.Func UDID VERSION PRODUCT - SERIAL + SERIAL PayloadOrganization diff --git a/server/mdm/assets/assets_test.go b/server/mdm/assets/assets_test.go index 2b60ac4aa3..b6484fd4b0 100644 --- a/server/mdm/assets/assets_test.go +++ b/server/mdm/assets/assets_test.go @@ -18,8 +18,8 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" "github.com/fleetdm/fleet/v4/server/mock" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) // generateTestCert generates a test certificate and key. diff --git a/server/mdm/mdm.go b/server/mdm/mdm.go index 93af98eb3d..5aaae483d8 100644 --- a/server/mdm/mdm.go +++ b/server/mdm/mdm.go @@ -6,7 +6,7 @@ import ( "crypto/x509" "encoding/base64" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // MaxProfileRetries is the maximum times an install profile command may be diff --git a/server/mdm/microsoft/microsoft_mdm.go b/server/mdm/microsoft/microsoft_mdm.go index a8a9254bd8..bc56572ee1 100644 --- a/server/mdm/microsoft/microsoft_mdm.go +++ b/server/mdm/microsoft/microsoft_mdm.go @@ -5,7 +5,7 @@ import ( "encoding/base64" "github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) const ( diff --git a/server/mdm/microsoft/wstep.go b/server/mdm/microsoft/wstep.go index 68346cde4f..beb0c01ac2 100644 --- a/server/mdm/microsoft/wstep.go +++ b/server/mdm/microsoft/wstep.go @@ -21,7 +21,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" "github.com/golang-jwt/jwt/v4" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // CertManager is an interface for certificate management tasks associated with Microsoft MDM (e.g., diff --git a/server/mdm/nanodep/tokenpki/parse.go b/server/mdm/nanodep/tokenpki/parse.go index 6d254403f1..296dc43edf 100644 --- a/server/mdm/nanodep/tokenpki/parse.go +++ b/server/mdm/nanodep/tokenpki/parse.go @@ -11,7 +11,7 @@ import ( "io" "net/textproto" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // UnwrapSMIME removes the S/MIME-like header wrapper around the raw encrypted diff --git a/server/mdm/nanomdm/cryptoutil/cryptoutil.go b/server/mdm/nanomdm/cryptoutil/cryptoutil.go index d7e41fcf10..ad62b4587a 100644 --- a/server/mdm/nanomdm/cryptoutil/cryptoutil.go +++ b/server/mdm/nanomdm/cryptoutil/cryptoutil.go @@ -10,7 +10,7 @@ import ( "fmt" "strings" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // OID for UID (User ID) attribute diff --git a/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go b/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go index 0c3eda3296..5b5291709c 100644 --- a/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go +++ b/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go @@ -4,7 +4,7 @@ import ( "encoding/base64" "testing" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) func TestPKCS7ParseTagLengthError(t *testing.T) { diff --git a/server/mdm/scep/scep/scep.go b/server/mdm/scep/scep/scep.go index 25fa1de349..dc2bb1fcc8 100644 --- a/server/mdm/scep/scep/scep.go +++ b/server/mdm/scep/scep/scep.go @@ -19,7 +19,7 @@ import ( "github.com/go-kit/log" "github.com/go-kit/log/level" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // errors diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 99d55d1215..2b933b50ca 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -756,6 +756,8 @@ type UpsertMDMAppleHostDEPAssignmentsFunc func(ctx context.Context, hosts []flee type IngestMDMAppleDevicesFromDEPSyncFunc func(ctx context.Context, devices []godep.Device, abmTokenID uint, macOSTeam *fleet.Team, iosTeam *fleet.Team, ipadTeam *fleet.Team) (int64, error) +type IngestMDMAppleDeviceFromOTAEnrollmentFunc func(ctx context.Context, teamID *uint, deviceInfo fleet.MDMAppleMachineInfo) error + type MDMAppleUpsertHostFunc func(ctx context.Context, mdmHost *fleet.Host) error type RestoreMDMApplePendingDEPHostFunc func(ctx context.Context, host *fleet.Host) error @@ -2173,6 +2175,9 @@ type DataStore struct { IngestMDMAppleDevicesFromDEPSyncFunc IngestMDMAppleDevicesFromDEPSyncFunc IngestMDMAppleDevicesFromDEPSyncFuncInvoked bool + IngestMDMAppleDeviceFromOTAEnrollmentFunc IngestMDMAppleDeviceFromOTAEnrollmentFunc + IngestMDMAppleDeviceFromOTAEnrollmentFuncInvoked bool + MDMAppleUpsertHostFunc MDMAppleUpsertHostFunc MDMAppleUpsertHostFuncInvoked bool @@ -5220,6 +5225,13 @@ func (s *DataStore) IngestMDMAppleDevicesFromDEPSync(ctx context.Context, device return s.IngestMDMAppleDevicesFromDEPSyncFunc(ctx, devices, abmTokenID, macOSTeam, iosTeam, ipadTeam) } +func (s *DataStore) IngestMDMAppleDeviceFromOTAEnrollment(ctx context.Context, teamID *uint, deviceInfo fleet.MDMAppleMachineInfo) error { + s.mu.Lock() + s.IngestMDMAppleDeviceFromOTAEnrollmentFuncInvoked = true + s.mu.Unlock() + return s.IngestMDMAppleDeviceFromOTAEnrollmentFunc(ctx, teamID, deviceInfo) +} + func (s *DataStore) MDMAppleUpsertHost(ctx context.Context, mdmHost *fleet.Host) error { s.mu.Lock() s.MDMAppleUpsertHostFuncInvoked = true diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 3072cd710c..da2c75f68f 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -44,6 +44,7 @@ import ( "github.com/go-kit/log/level" "github.com/google/uuid" "github.com/groob/plist" + "go.mozilla.org/pkcs7" ) type getMDMAppleCommandResultsRequest struct { @@ -1276,8 +1277,7 @@ func (mdmAppleEnrollRequest) DecodeRequest(ctx context.Context, r *http.Request) InternalErr: err, } } - p := fleet.MDMAppleMachineInfo(*parsed) - decoded.MachineInfo = &p + decoded.MachineInfo = parsed } return &decoded, nil @@ -1456,7 +1456,7 @@ func (svc *Service) needsOSUpdateForDEPEnrollment(ctx context.Context, m fleet.M } func (svc *Service) getAppleSoftwareUpdateRequiredForDEPEnrollment(m fleet.MDMAppleMachineInfo) (*fleet.MDMAppleSoftwareUpdateRequired, error) { - latest, err := gdmf.GetLatestOSVersion(apple_mdm.MachineInfo(m)) + latest, err := gdmf.GetLatestOSVersion(m) if err != nil { return nil, err } @@ -4189,3 +4189,182 @@ func (svc *Service) GetOTAProfile(ctx context.Context, enrollSecret string) ([]b return signed, nil } + +//////////////////////////////////////////////////////////////////////////////// +// POST /ota_enrollment?enroll_secret=xyz +//////////////////////////////////////////////////////////////////////////////// + +type mdmAppleOTARequest struct { + EnrollSecret string `query:"enroll_secret"` + Certificates []*x509.Certificate + RootSigner *x509.Certificate + DeviceInfo fleet.MDMAppleMachineInfo +} + +func (mdmAppleOTARequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + enrollSecret := r.URL.Query().Get("enroll_secret") + if enrollSecret == "" { + return nil, &fleet.BadRequestError{ + Message: "enroll_secret query parameter is required", + } + } + + rawData, err := io.ReadAll(r.Body) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "reading body from request") + } + + p7, err := pkcs7.Parse(rawData) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "invalid request body", + InternalErr: err, + } + } + + var request mdmAppleOTARequest + err = plist.Unmarshal(p7.Content, &request.DeviceInfo) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "invalid request body", + InternalErr: err, + } + } + + if request.DeviceInfo.Serial == "" { + return nil, &fleet.BadRequestError{ + Message: "SERIAL is required", + } + } + + request.EnrollSecret = enrollSecret + request.Certificates = p7.Certificates + request.RootSigner = p7.GetOnlySigner() + return &request, nil +} + +type mdmAppleOTAResponse struct { + Err error `json:"error,omitempty"` + xml []byte +} + +func (r mdmAppleOTAResponse) error() error { return r.Err } + +func (r mdmAppleOTAResponse) hijackRender(ctx context.Context, w http.ResponseWriter) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(r.xml))) + w.Header().Set("Content-Type", "application/x-apple-aspen-config") + w.Header().Set("X-Content-Type-Options", "nosniff") + if _, err := w.Write(r.xml); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +func mdmAppleOTAEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*mdmAppleOTARequest) + xml, err := svc.MDMAppleProcessOTAEnrollment(ctx, req.Certificates, req.RootSigner, req.EnrollSecret, req.DeviceInfo) + if err != nil { + return mdmAppleGetInstallerResponse{Err: err}, nil + } + return mdmAppleOTAResponse{xml: xml}, nil +} + +// NOTE: this method and how OTA works is documented in full in the interface definition. +func (svc *Service) MDMAppleProcessOTAEnrollment( + ctx context.Context, + certificates []*x509.Certificate, + rootSigner *x509.Certificate, + enrollSecret string, + deviceInfo fleet.MDMAppleMachineInfo, +) ([]byte, error) { + // authorization is performed via the enroll secret and the provided certificates + svc.authz.SkipAuthorization(ctx) + + if len(certificates) == 0 { + return nil, authz.ForbiddenWithInternal("no certificates provided", nil, nil, nil) + } + + // first check is for the enroll secret, we'll only let the host + // through if it has a valid secret. + enrollSecretInfo, err := svc.ds.VerifyEnrollSecret(ctx, enrollSecret) + if err != nil { + if fleet.IsNotFound(err) { + return nil, authz.ForbiddenWithInternal("invalid enroll secret provided", nil, nil, nil) + } + + return nil, ctxerr.Wrap(ctx, err, "validating enroll secret") + } + + assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ + fleet.MDMAssetSCEPChallenge, + }) + if err != nil { + return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err) + } + scepChallenge := string(assets[fleet.MDMAssetSCEPChallenge].Value) + + appCfg, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "reading app config") + } + fleetURL := appCfg.ServerSettings.ServerURL + + // if the root signer was issued by Apple's CA, it means we're in the + // first phase and we should return a SCEP payload. + if err := apple_mdm.VerifyFromAppleIphoneDeviceCA(rootSigner); err == nil { + scepURL, err := apple_mdm.ResolveAppleSCEPURL(fleetURL) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "resolve Apple SCEP url") + } + + var buf bytes.Buffer + if err := apple_mdm.OTASCEPTemplate.Execute(&buf, struct { + SCEPURL string + SCEPChallenge string + }{ + SCEPURL: scepURL, + SCEPChallenge: scepChallenge, + }); err != nil { + return nil, ctxerr.Wrap(ctx, err, "execute template") + } + return buf.Bytes(), nil + } + + // otherwise we might be in the second phase, check if the signing cert + // was issued by Fleet, only let the enrollment through if so. + certVerifier := mdmcrypto.NewSCEPVerifier(svc.ds) + if err := certVerifier.Verify(rootSigner); err != nil { + return nil, authz.ForbiddenWithInternal(fmt.Sprintf("payload signed with invalid certificate: %s", err), nil, nil, nil) + } + + topic, err := svc.mdmPushCertTopic(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert") + } + + enrollmentProf, err := apple_mdm.GenerateEnrollmentProfileMobileconfig( + appCfg.OrgInfo.OrgName, + appCfg.ServerSettings.ServerURL, + string(assets[fleet.MDMAssetSCEPChallenge].Value), + topic, + ) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "generating manual enrollment profile") + } + + // before responding, create a host record, and assign the host to the + // team that matches the enroll secret provided. + err = svc.ds.IngestMDMAppleDeviceFromOTAEnrollment(ctx, enrollSecretInfo.TeamID, deviceInfo) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "creating new host record") + } + + // at this point we know the device can be enrolled, so we respond with + // a signed enrollment profile + signed, err := mdmcrypto.Sign(ctx, enrollmentProf, svc.ds) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "signing profile") + } + + return signed, nil +} diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index a8b20b04ec..3db0377126 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -49,9 +49,9 @@ import ( "github.com/google/uuid" "github.com/groob/plist" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) type nopProfileMatcher struct{} diff --git a/server/service/handler.go b/server/service/handler.go index 23b30a3461..c7b8c11cb4 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -870,6 +870,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC neAppleMDM.GET(apple_mdm.EnrollPath, mdmAppleEnrollEndpoint, mdmAppleEnrollRequest{}) neAppleMDM.GET(apple_mdm.InstallerPath, mdmAppleGetInstallerEndpoint, mdmAppleGetInstallerRequest{}) neAppleMDM.HEAD(apple_mdm.InstallerPath, mdmAppleHeadInstallerEndpoint, mdmAppleHeadInstallerRequest{}) + neAppleMDM.POST("/api/_version_/fleet/ota_enrollment", mdmAppleOTAEndpoint, mdmAppleOTARequest{}) // Deprecated: GET /mdm/bootstrap is now deprecated, replaced by the // GET /bootstrap endpoint. diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index d44f9fcf4d..26611cb829 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -26,9 +26,9 @@ import ( "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" kitlog "github.com/go-kit/log" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) func TestHostDetails(t *testing.T) { diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index b487cd1db3..27c1458c40 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -28,8 +28,8 @@ import ( "github.com/groob/plist" "github.com/jmoiron/sqlx" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) // NOTE: the mantra for lifecycle events is: diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index c60d0580e2..fdec8458e2 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -30,9 +30,9 @@ import ( "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) func (s *integrationMDMTestSuite) signedProfilesMatch(want, got [][]byte) { diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 63ab882da1..9fcd7ec7a4 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -66,10 +66,10 @@ import ( "github.com/groob/plist" "github.com/jmoiron/sqlx" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - "go.mozilla.org/pkcs7" ) func TestIntegrationsMDM(t *testing.T) { @@ -10993,3 +10993,145 @@ func (s *integrationMDMTestSuite) TestEnrollmentProfilesWithSpecialChars() { require.NoError(t, err) require.Equal(t, enrollSecretWithInvalidChars, parsedData.PayloadContent[0].EnrollSecret) } + +func (s *integrationMDMTestSuite) TestOTAEnrollment() { + t := s.T() + + // create a global enroll secret + globalSecret := "global_secret" + var applyResp applyEnrollSecretSpecResponse + s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{ + Spec: &fleet.EnrollSecretSpec{ + Secrets: []*fleet.EnrollSecret{{Secret: globalSecret}}, + }, + }, http.StatusOK, &applyResp) + + reqBody := []byte(` + + + + PRODUCT + + SERIAL + foo + UDID + + VERSION + + +`) + + // request with no enroll secret + httpResp := s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment", reqBody, http.StatusBadRequest) + errMsg := extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "enroll_secret query parameter is required") + require.NoError(t, httpResp.Body.Close()) + + // request with no body + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", nil, http.StatusBadRequest) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "invalid request body") + require.NoError(t, httpResp.Body.Close()) + + // request with unsigned body + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", reqBody, http.StatusBadRequest) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "invalid request body") + require.NoError(t, httpResp.Body.Close()) + + cert, key, err := apple_mdm.NewSCEPCACertKey() + require.NoError(t, err) + signedData, err := pkcs7.NewSignedData(reqBody) + require.NoError(t, err) + require.NoError(t, signedData.AddSigner(cert, key, pkcs7.SignerInfoConfig{})) + signedReqBody, err := signedData.Finish() + require.NoError(t, err) + + // request with invalid apple signature + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", signedReqBody, http.StatusForbidden) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "forbidden") + require.NoError(t, httpResp.Body.Close()) + + // request with invalid device signature + os.Setenv("FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY", "1") + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", signedReqBody, http.StatusForbidden) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "forbidden") + require.NoError(t, httpResp.Body.Close()) + + // request without serial number + signedData, err = pkcs7.NewSignedData([]byte(` + + + + SERIAL + + +`)) + require.NoError(t, err) + require.NoError(t, signedData.AddSigner(cert, key, pkcs7.SignerInfoConfig{})) + signedReqBody, err = signedData.Finish() + require.NoError(t, err) + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", signedReqBody, http.StatusBadRequest) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "SERIAL is required") + require.NoError(t, httpResp.Body.Close()) + + checkInstallFleetdCommandSent := func(mdmDevice *mdmtest.TestAppleMDMClient, wantCommand bool) { + foundInstallFleetdCommand := false + cmd, err := mdmDevice.Idle() + require.NoError(t, err) + for cmd != nil { + var fullCmd micromdm.CommandPayload + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + if manifest := fullCmd.Command.InstallEnterpriseApplication.ManifestURL; manifest != nil { + foundInstallFleetdCommand = true + require.Equal(t, "InstallEnterpriseApplication", cmd.Command.RequestType) + require.Contains(t, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL, fleetdbase.GetPKGManifestURL()) + } + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + require.Equal(t, wantCommand, foundInstallFleetdCommand) + } + + hwModel := "MacBookPro16,1" + mdmDevice := mdmtest.NewTestMDMClientAppleOTA( + s.server.URL, + globalSecret, + hwModel, + ) + require.NoError(t, mdmDevice.Enroll()) + s.runWorker() + checkInstallFleetdCommandSent(mdmDevice, true) + + var hostByIdentifierResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", mdmDevice.UUID), nil, http.StatusOK, &hostByIdentifierResp) + require.Equal(t, hwModel, hostByIdentifierResp.Host.HardwareModel) + require.Equal(t, "darwin", hostByIdentifierResp.Host.Platform) + require.Nil(t, hostByIdentifierResp.Host.TeamID) + + // create a team with a different enroll secret + var specResp applyTeamSpecsResponse + teamSecret := "team_secret" + teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{Name: "newteam", Secrets: &[]fleet.EnrollSecret{{Secret: teamSecret}}}}} + s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &specResp) + + hwModel = "iPad13,16" + mdmDevice = mdmtest.NewTestMDMClientAppleOTA( + s.server.URL, + teamSecret, + hwModel, + ) + require.NoError(t, mdmDevice.Enroll()) + s.runWorker() + checkInstallFleetdCommandSent(mdmDevice, false) + + hostByIdentifierResp = getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", mdmDevice.UUID), nil, http.StatusOK, &hostByIdentifierResp) + require.Equal(t, hwModel, hostByIdentifierResp.Host.HardwareModel) + require.Equal(t, "ipados", hostByIdentifierResp.Host.Platform) + require.NotNil(t, hostByIdentifierResp.Host.TeamID) + require.Equal(t, specResp.TeamIDsByName["newteam"], *hostByIdentifierResp.Host.TeamID) +}