mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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:
parent
9566b7d320
commit
05818902cd
35 changed files with 936 additions and 202 deletions
1
changes/21019-ota-enrollment
Normal file
1
changes/21019-ota-enrollment
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Implement protocol support for OTA enrollment and automatic team assignment for hosts.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
3
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
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
BIN
server/mdm/apple/AppleIphoneDeviceCA.cer
Normal file
BIN
server/mdm/apple/AppleIphoneDeviceCA.cer
Normal file
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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.,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.mozilla.org/pkcs7"
|
||||
"github.com/smallstep/pkcs7"
|
||||
)
|
||||
|
||||
// OID for UID (User ID) attribute
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"go.mozilla.org/pkcs7"
|
||||
"github.com/smallstep/pkcs7"
|
||||
)
|
||||
|
||||
func TestPKCS7ParseTagLengthError(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue