feat: get Apple MDM CSR endpoint (#19253)

> Related issue: #19014

# 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://fleetdm.com/docs/contributing/committing-changes#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:
Jahziel Villasana-Espinoza 2024-05-24 14:38:57 -04:00 committed by GitHub
commit 23e773c213
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 310 additions and 11 deletions

View file

@ -38,6 +38,5 @@
"prettier.requireConfig": true,
"yaml.schemas": {
"https://json.schemastore.org/codecov.json": ".github/workflows/codecov.yml"
},
"favorites.sortOrder": "ASC"
}
}

View file

@ -0,0 +1,2 @@
- Adds a `GET /fleet/mdm/apple/request_csr` endpoint, which returns the signed APNS CSR needed to
activate Apple MDM.

View file

@ -4145,3 +4145,27 @@ VALUES
return ctxerr.Wrap(ctx, err, "writing mdm config assets to db")
}
func (ds *Datastore) GetMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) ([]fleet.MDMConfigAsset, error) {
stmt := `
SELECT
name, value
FROM
mdm_config_assets
WHERE
name IN (?)
AND deletion_uuid = ''
`
stmt, args, err := sqlx.In(stmt, assetNames)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "sqlx.In GetMDMConfigAssetsByName")
}
var res []fleet.MDMConfigAsset
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &res, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get mdm config assets by name")
}
return res, nil
}

View file

@ -74,7 +74,7 @@ func TestMDMApple(t *testing.T) {
{"MDMAppleSetPendingDeclarationsAs", testMDMAppleSetPendingDeclarationsAs},
{"SetOrUpdateMDMAppleDeclaration", testSetOrUpdateMDMAppleDDMDeclaration},
{"DEPAssignmentUpdates", testMDMAppleDEPAssignmentUpdates},
{"TestInsertMDMAsset", testInsertMDMAsset},
{"TestInsertMDMAsset", testMDMConfigAsset},
}
for _, c := range cases {
@ -5499,7 +5499,7 @@ func createRawAppleCmd(reqType, cmdUUID string) string {
</plist>`, reqType, cmdUUID)
}
func testInsertMDMAsset(t *testing.T, ds *Datastore) {
func testMDMConfigAsset(t *testing.T, ds *Datastore) {
ctx := context.Background()
assets := []fleet.MDMConfigAsset{
{
@ -5508,15 +5508,14 @@ func testInsertMDMAsset(t *testing.T, ds *Datastore) {
},
{
Name: fleet.MDMAssetCAKey,
Value: []byte("some bytes"),
Value: []byte("some other bytes"),
},
}
err := ds.InsertMDMConfigAssets(ctx, assets)
require.NoError(t, err)
var a []fleet.MDMConfigAsset
require.NoError(t, sqlx.SelectContext(ctx, ds.reader(ctx), &a, `SELECT name, value FROM mdm_config_assets`))
require.Len(t, a, 2)
a, err := ds.GetMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
require.NoError(t, err)
require.Equal(t, assets, a)
}

View file

@ -1249,8 +1249,12 @@ type Datastore interface {
// the provided value.
MDMAppleSetPendingDeclarationsAs(ctx context.Context, hostUUID string, status *MDMDeliveryStatus, detail string) error
// InsertMDMConfigAssets inserts MDM related config assets, such as SCEP and APNS certs and keys.
InsertMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset) error
// GetMDMConfigAssetsByName returns the requested config assets.
GetMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName) ([]MDMConfigAsset, error)
///////////////////////////////////////////////////////////////////////////////
// Microsoft MDM

View file

@ -689,6 +689,11 @@ type Service interface {
GetAppleBM(ctx context.Context) (*AppleBM, error)
RequestMDMAppleCSR(ctx context.Context, email, org string) (*AppleCSR, error)
// GetMDMAppleCSR returns a signed CSR as base64 encoded bytes for Apple MDM. The first time
// this method is called, it will create a SCEP certificate, a SCEP key, and an APNS key and
// write these to the DB. On subsequent calls, it will use the saved APNS key for generating the CSR.
GetMDMAppleCSR(ctx context.Context) ([]byte, error)
// GetHostDEPAssignment retrieves the host DEP assignment for the specified host.
GetHostDEPAssignment(ctx context.Context, host *Host) (*HostDEPAssignment, error)

View file

@ -60,6 +60,36 @@ func GenerateAPNSCSRKey(email, org string) (*x509.CertificateRequest, *rsa.Priva
return certReq, key, nil
}
func GenerateAPNSCSR(org, email string, key *rsa.PrivateKey) (*x509.CertificateRequest, error) {
subj := pkix.Name{
Organization: []string{org},
ExtraNames: []pkix.AttributeTypeAndValue{{
Type: emailAddressOID,
Value: email,
}},
}
template := &x509.CertificateRequest{
Subject: subj,
SignatureAlgorithm: x509.SHA256WithRSA,
}
b, err := x509.CreateCertificateRequest(rand.Reader, template, key)
if err != nil {
return nil, err
}
certReq, err := x509.ParseCertificateRequest(b)
if err != nil {
return nil, err
}
return certReq, nil
}
func NewPrivateKey() (*rsa.PrivateKey, error) {
return newPrivateKey()
}
type FleetWebsiteError struct {
Status int
message string
@ -116,6 +146,55 @@ func GetSignedAPNSCSR(client *http.Client, csr *x509.CertificateRequest) error {
return nil
}
type WebsiteResponse struct {
CSR []byte `json:"csr"`
}
// GetSignedAPNSCSRNoEmail makes a request to the fleetdm.com API to get a signed APNs
// CSR and returns the signed CSR directly.
func GetSignedAPNSCSRNoEmail(client *http.Client, csr *x509.CertificateRequest) ([]byte, error) {
csrPEM := EncodeCertRequestPEM(csr)
payload := getSignedAPNSCSRRequest{
UnsignedCSRData: csrPEM,
}
b, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
// for testing
baseURL := defaultFleetDMAPIURL
if x := os.Getenv("TEST_FLEETDM_API_URL"); x != "" {
baseURL = strings.TrimRight(x, "/")
}
u := baseURL + getSignedAPNSCSRPath + "?deliveryMethod=json"
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("creating csr signing request for fleetdm api: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("sending csr signing request to fleetdm api: %w", err)
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, FleetWebsiteError{Status: resp.StatusCode, message: string(respBytes)}
}
var csrResp WebsiteResponse
if err := json.Unmarshal(respBytes, &csrResp); err != nil {
return nil, fmt.Errorf("unmarshalling signed csr response from fleetdm api: %w", err)
}
return csrResp.CSR, nil
}
// NewSCEPCACertKey creates a self-signed CA certificate for use with SCEP and
// returns the certificate and its private key.
func NewSCEPCACertKey() (*x509.Certificate, *rsa.PrivateKey, error) {

View file

@ -823,6 +823,8 @@ type MDMAppleSetPendingDeclarationsAsFunc func(ctx context.Context, hostUUID str
type InsertMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset) error
type GetMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) ([]fleet.MDMConfigAsset, error)
type WSTEPStoreCertificateFunc func(ctx context.Context, name string, crt *x509.Certificate) error
type WSTEPNewSerialFunc func(ctx context.Context) (*big.Int, error)
@ -2162,6 +2164,9 @@ type DataStore struct {
InsertMDMConfigAssetsFunc InsertMDMConfigAssetsFunc
InsertMDMConfigAssetsFuncInvoked bool
GetMDMConfigAssetsByNameFunc GetMDMConfigAssetsByNameFunc
GetMDMConfigAssetsByNameFuncInvoked bool
WSTEPStoreCertificateFunc WSTEPStoreCertificateFunc
WSTEPStoreCertificateFuncInvoked bool
@ -5177,6 +5182,13 @@ func (s *DataStore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MD
return s.InsertMDMConfigAssetsFunc(ctx, assets)
}
func (s *DataStore) GetMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) ([]fleet.MDMConfigAsset, error) {
s.mu.Lock()
s.GetMDMConfigAssetsByNameFuncInvoked = true
s.mu.Unlock()
return s.GetMDMConfigAssetsByNameFunc(ctx, assetNames)
}
func (s *DataStore) WSTEPStoreCertificate(ctx context.Context, name string, crt *x509.Certificate) error {
s.mu.Lock()
s.WSTEPStoreCertificateFuncInvoked = true

View file

@ -495,6 +495,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
// Generative AI
ue.POST("/api/_version_/fleet/autofill/policy", autofillPoliciesEndpoint, autofillPoliciesRequest{})
ue.GET("/api/_version_/fleet/mdm/apple/request_csr", getMDMAppleCSREndpoint, getMDMAppleCSRRequest{})
// Only Fleet MDM specific endpoints should be within the root /mdm/ path.
// NOTE: remember to update
// `service.mdmConfigurationRequiredEndpoints` when you add an

View file

@ -270,7 +270,13 @@ func (s *integrationMDMTestSuite) SetupSuite() {
fleetdmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
status := s.fleetDMNextCSRStatus.Swap(http.StatusOK)
w.WriteHeader(status.(int))
_, _ = w.Write([]byte(fmt.Sprintf("status: %d", status)))
resp := []byte(fmt.Sprintf("status: %d", status))
if status == http.StatusOK && strings.Contains(r.URL.RawQuery, "deliveryMethod=json") {
resp = []byte(fmt.Sprintf(`{"csr": "%s"}`, base64.StdEncoding.EncodeToString([]byte(`-----BEGIN CERTIFICATE REQUEST-----
foobar
-----END CERTIFICATE REQUEST-----`))))
}
_, _ = w.Write(resp)
}))
s.T().Setenv("TEST_FLEETDM_API_URL", fleetdmSrv.URL)
@ -893,6 +899,45 @@ func (s *integrationMDMTestSuite) TestAppleMDMCSRRequest() {
require.Contains(t, string(reqCSRResp.SCEPKey), "-----BEGIN RSA PRIVATE KEY-----\n")
}
func (s *integrationMDMTestSuite) TestGetMDMCSR() {
t := s.T()
ctx := context.Background()
// Check that we return bad gateway if the website API errors
s.FailNextCSRRequestWith(http.StatusInternalServerError)
errResp := validationErrResp{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/request_csr", getMDMAppleCSRRequest{}, http.StatusBadGateway, &errResp)
require.Len(t, errResp.Errors, 1)
require.Contains(t, errResp.Errors[0].Reason, "FleetDM CSR request failed")
// Successful request
resp := getMDMAppleCSRResponse{}
s.SucceedNextCSRRequest()
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/request_csr", getMDMAppleCSRRequest{}, http.StatusOK, &resp)
require.NotNil(t, resp.CSR)
require.Equal(t, string(resp.CSR), `-----BEGIN CERTIFICATE REQUEST-----
foobar
-----END CERTIFICATE REQUEST-----`)
// Check that we created the right assets
assetsFromCall1, err := s.ds.GetMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey, fleet.MDMAssetAPNSKey})
require.NoError(t, err)
require.Len(t, assetsFromCall1, 3)
resp = getMDMAppleCSRResponse{}
s.SucceedNextCSRRequest()
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/request_csr", getMDMAppleCSRRequest{}, http.StatusOK, &resp)
require.NotNil(t, resp.CSR)
require.Equal(t, string(resp.CSR), `-----BEGIN CERTIFICATE REQUEST-----
foobar
-----END CERTIFICATE REQUEST-----`)
// Check that the assets stayed the same in the subsequent call
assetsFromCall2, err := s.ds.GetMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey, fleet.MDMAssetAPNSKey})
require.NoError(t, err)
require.Equal(t, assetsFromCall1, assetsFromCall2)
}
func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() {
t := s.T()

View file

@ -3,7 +3,10 @@ package service
import (
"bytes"
"context"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
@ -108,7 +111,7 @@ func (svc *Service) GetAppleBM(ctx context.Context) (*fleet.AppleBM, error) {
}
////////////////////////////////////////////////////////////////////////////////
// GET /mdm/apple/request_csr
// POST /mdm/apple/request_csr
////////////////////////////////////////////////////////////////////////////////
type requestMDMAppleCSRRequest struct {
@ -2109,3 +2112,115 @@ func (svc *Service) ResendHostMDMProfile(ctx context.Context, hostID uint, profi
return nil
}
////////////////////////////////////////////////////////////////////////////////
// GET /mdm/apple/request_csr
////////////////////////////////////////////////////////////////////////////////
type getMDMAppleCSRRequest struct{}
type getMDMAppleCSRResponse struct {
CSR []byte `json:"csr"` // base64 encoded
Err error `json:"error,omitempty"`
}
func (r getMDMAppleCSRResponse) error() error { return r.Err }
func getMDMAppleCSREndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
signedCSRB64, err := svc.GetMDMAppleCSR(ctx)
if err != nil {
return &getMDMAppleCSRResponse{Err: err}, nil
}
return &getMDMAppleCSRResponse{CSR: signedCSRB64}, nil
}
func (svc *Service) GetMDMAppleCSR(ctx context.Context) ([]byte, error) {
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
return nil, err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
// Check if we have existing certs and keys
var apnsKey *rsa.PrivateKey
savedAssets, err := svc.ds.GetMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey, fleet.MDMAssetAPNSKey})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "checking asset existence")
}
if len(savedAssets) == 0 {
// Then we should create them
scepCert, scepKey, err := apple_mdm.NewSCEPCACertKey()
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "generate SCEP cert and key")
}
apnsKey, err = apple_mdm.NewPrivateKey()
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "generate new apns private key")
}
// Store our config assets
var assets []fleet.MDMConfigAsset
for k, v := range map[fleet.MDMAssetName][]byte{
fleet.MDMAssetCACert: apple_mdm.EncodeCertPEM(scepCert),
fleet.MDMAssetCAKey: apple_mdm.EncodePrivateKeyPEM(scepKey),
fleet.MDMAssetAPNSKey: apple_mdm.EncodePrivateKeyPEM(apnsKey),
} {
assets = append(assets, fleet.MDMConfigAsset{
Name: k,
Value: v,
})
}
if err := svc.ds.InsertMDMConfigAssets(ctx, assets); err != nil {
return nil, ctxerr.Wrap(ctx, err, "inserting mdm config assets")
}
} else {
for _, a := range savedAssets {
if a.Name == fleet.MDMAssetAPNSKey {
block, _ := pem.Decode(a.Value)
apnsKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "unmarshaling saved apns key")
}
}
}
}
// Generate new APNS CSR every time this is called
appConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get app config")
}
apnsCSR, err := apple_mdm.GenerateAPNSCSR(appConfig.OrgInfo.OrgName, vc.Email(), apnsKey)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "generate APNS cert and key")
}
// Submit CSR to fleetdm.com for signing
websiteClient := fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second))
signedCSRB64, err := apple_mdm.GetSignedAPNSCSRNoEmail(websiteClient, apnsCSR)
if err != nil {
var fwe apple_mdm.FleetWebsiteError
if errors.As(err, &fwe) {
return nil, ctxerr.Wrap(
ctx,
fleet.NewUserMessageError(
fmt.Errorf("FleetDM CSR request failed: %w", err),
http.StatusBadGateway,
),
)
}
return nil, ctxerr.Wrap(ctx, err, "get signed CSR")
}
// Return signed CSR; these bytes are already base64 encoded
return signedCSRB64, nil
}

View file

@ -60,6 +60,16 @@ func TestMDMAppleAuthorization(t *testing.T) {
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
ds.GetMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) ([]fleet.MDMConfigAsset, error) {
return []fleet.MDMConfigAsset{}, nil
}
ds.InsertMDMConfigAssetsFunc = func(ctx context.Context, assets []fleet.MDMConfigAsset) error { return nil }
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{OrgInfo: fleet.OrgInfo{OrgName: "Nurv"}}, nil
}
// use a custom implementation of checkAuthErr as the service call will fail
// with a not found error (given that MDM is not really configured) in case
// of success, and the package-wide checkAuthErr requires no error.
@ -82,6 +92,9 @@ func TestMDMAppleAuthorization(t *testing.T) {
_, err = svc.RequestMDMAppleCSR(ctx, "not-an-email", "")
require.Error(t, err) // it *will* always fail, but not necessarily due to authorization
checkAuthErr(t, shouldFailWithAuth, err)
_, err = svc.GetMDMAppleCSR(ctx)
checkAuthErr(t, shouldFailWithAuth, err)
}
// Only global admins can access the endpoints.