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 0000000000..fac79ff89d
Binary files /dev/null and b/server/mdm/apple/AppleIphoneDeviceCA.cer differ
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)
+}