implement OTA enrollment (#21942)

for #21019

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [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
This commit is contained in:
Roberto Dip 2024-09-10 16:52:17 -03:00 committed by GitHub
parent 9566b7d320
commit 05818902cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 936 additions and 202 deletions

View file

@ -0,0 +1 @@
* Implement protocol support for OTA enrollment and automatic team assignment for hosts.

View file

@ -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

View file

@ -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";

3
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

View file

@ -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(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PRODUCT</key>
<string>%s</string>
<key>SERIAL</key>
<string>%s</string>
<key>UDID</key>
<string>%s</string>
<key>VERSION</key>
<string>22A5316k</string>
</dict>
</plist>`, 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
}

View file

@ -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

View file

@ -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()

View file

@ -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 (

View file

@ -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) {

View file

@ -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

View file

@ -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

Binary file not shown.

View file

@ -800,6 +800,62 @@ var funcMap = map[string]any{
"xml": mobileconfig.XMLEscapeString,
}
var OTASCEPTemplate = template.Must(template.New("").Funcs(funcMap).Parse(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Inc//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadIdentifier</key>
<string>Ignored</string>
<key>PayloadUUID</key>
<string>Ignored</string>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadContent</key>
<dict>
<key>Key Type</key>
<string>RSA</string>
<key>Challenge</key>
<string>{{ .SCEPChallenge | xml }}</string>
<key>Key Usage</key>
<integer>5</integer>
<key>Keysize</key>
<integer>2048</integer>
<key>URL</key>
<string>{{ .SCEPURL }}</string>
<key>Subject</key>
<array>
<array>
<array>
<string>O</string>
<string>Fleet</string>
</array>
</array>
<array>
<array>
<string>CN</string>
<string>Fleet Identity</string>
</array>
</array>
</array>
</dict>
<key>PayloadIdentifier</key>
<string>com.fleetdm.fleet.mdm.apple.scep</string>
<key>PayloadType</key>
<string>com.apple.security.scep</string>
<key>PayloadUUID</key>
<string>BCA53F9D-5DD2-494D-98D3-0D0F20FF6BA1</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
</dict>
</plist>`))
// 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

View file

@ -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) {

View file

@ -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
}

View file

@ -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)

View file

@ -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",

View file

@ -138,7 +138,7 @@ var OTAMobileConfigTemplate = template.Must(template.New("").Funcs(template.Func
<string>UDID</string>
<string>VERSION</string>
<string>PRODUCT</string>
<string>SERIAL</string>
<string>SERIAL</string>
</array>
</dict>
<key>PayloadOrganization</key>

View file

@ -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.

View file

@ -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

View file

@ -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 (

View file

@ -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.,

View file

@ -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

View file

@ -10,7 +10,7 @@ import (
"fmt"
"strings"
"go.mozilla.org/pkcs7"
"github.com/smallstep/pkcs7"
)
// OID for UID (User ID) attribute

View file

@ -4,7 +4,7 @@ import (
"encoding/base64"
"testing"
"go.mozilla.org/pkcs7"
"github.com/smallstep/pkcs7"
)
func TestPKCS7ParseTagLengthError(t *testing.T) {

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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{}

View file

@ -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.

View file

@ -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) {

View file

@ -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:

View file

@ -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) {

View file

@ -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(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PRODUCT</key>
<string></string>
<key>SERIAL</key>
<string>foo</string>
<key>UDID</key>
<string></string>
<key>VERSION</key>
<string></string>
</dict>
</plist>`)
// 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(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SERIAL</key>
<string></string>
</dict>
</plist>`))
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)
}