From e11f44a89bbe9550064fc3df86770c63abad1259 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Mon, 27 May 2024 10:13:08 -0400 Subject: [PATCH 1/2] feat: upload and delete APNS certs (#19275) > Related issue: #19014 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://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 --------- Co-authored-by: Roberto Dip --- changes/post-apns-cert | 2 + server/datastore/mysql/apple_mdm.go | 22 ++++ server/datastore/mysql/apple_mdm_test.go | 32 +++++ server/fleet/datastore.go | 3 + server/fleet/service.go | 3 + server/mdm/apple/cert.go | 9 +- server/mock/datastore_mock.go | 12 ++ server/service/handler.go | 6 +- server/service/integration_mdm_test.go | 108 ++++++++++++++-- server/service/mdm.go | 149 ++++++++++++++++++++++- server/service/mdm_test.go | 10 ++ 11 files changed, 337 insertions(+), 19 deletions(-) create mode 100644 changes/post-apns-cert diff --git a/changes/post-apns-cert b/changes/post-apns-cert new file mode 100644 index 0000000000..a68cbeba1a --- /dev/null +++ b/changes/post-apns-cert @@ -0,0 +1,2 @@ +- Adds 2 new endpoints: `POST` and `DELETE /fleet/mdm/apple/apns_certificate`. These endpoints let + users manage APNS certificates in Fleet. \ No newline at end of file diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 872e8e1b06..e430eeb0ab 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -4169,3 +4169,25 @@ WHERE return res, nil } + +func (ds *Datastore) DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) error { + stmt := ` +UPDATE + mdm_config_assets +SET + deleted_at = CURRENT_TIMESTAMP(), + deletion_uuid = ? +WHERE + name IN (?) AND deletion_uuid = '' + ` + + deletionUUID := uuid.New().String() + + stmt, args, err := sqlx.In(stmt, deletionUUID, assetNames) + if err != nil { + return ctxerr.Wrap(ctx, err, "sqlx.In DeleteMDMConfigAssetsByName") + } + + _, err = ds.writer(ctx).ExecContext(ctx, stmt, args...) + return ctxerr.Wrap(ctx, err, "deleting mdm config assets") +} diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index ae2e355722..f2ecc96d5c 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -5518,4 +5518,36 @@ func testMDMConfigAsset(t *testing.T, ds *Datastore) { a, err := ds.GetMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}) require.NoError(t, err) require.Equal(t, assets, a) + + // Soft delete the assets + + err = ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}) + require.NoError(t, err) + + a, err = ds.GetMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}) + require.NoError(t, err) + require.Len(t, a, 0) + + // Verify that they're still in the DB + + type assetRow struct { + Name string `db:"name"` + Value []byte `db:"value"` + DeletionUUID string `db:"deletion_uuid"` + DeletedAt time.Time `db:"deleted_at"` + } + + var ar []assetRow + + err = sqlx.SelectContext(ctx, ds.reader(ctx), &ar, "SELECT name, value, deletion_uuid, deleted_at FROM mdm_config_assets WHERE name IN (?, ?) ORDER BY name", fleet.MDMAssetCACert, fleet.MDMAssetCAKey) + require.NoError(t, err) + + require.Len(t, ar, 2) + + for i, a := range ar { + require.Equal(t, assets[i].Name, fleet.MDMAssetName(a.Name)) + require.Equal(t, assets[i].Value, a.Value) + require.NotEmpty(t, a.DeletionUUID) + require.NotEmpty(t, a.DeletedAt) + } } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index e7cef30d2f..79c873969b 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1255,6 +1255,9 @@ type Datastore interface { // GetMDMConfigAssetsByName returns the requested config assets. GetMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName) ([]MDMConfigAsset, error) + // DeleteMDMConfigAssetsByName soft deletes the given MDM config assets. + DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName) error + /////////////////////////////////////////////////////////////////////////////// // Microsoft MDM diff --git a/server/fleet/service.go b/server/fleet/service.go index ff29c68a8e..23e131ccb1 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -694,6 +694,9 @@ type Service interface { // 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) + UploadMDMAppleAPNSCert(ctx context.Context, cert io.ReadSeeker) error + DeleteMDMAppleAPNSCert(ctx context.Context) error + // GetHostDEPAssignment retrieves the host DEP assignment for the specified host. GetHostDEPAssignment(ctx context.Context, host *Host) (*HostDEPAssignment, error) diff --git a/server/mdm/apple/cert.go b/server/mdm/apple/cert.go index 5edfa5bb6d..ec47d0d438 100644 --- a/server/mdm/apple/cert.go +++ b/server/mdm/apple/cert.go @@ -146,7 +146,7 @@ func GetSignedAPNSCSR(client *http.Client, csr *x509.CertificateRequest) error { return nil } -type WebsiteResponse struct { +type websiteSignCSRResponse struct { CSR []byte `json:"csr"` } @@ -182,12 +182,15 @@ func GetSignedAPNSCSRNoEmail(client *http.Client, csr *x509.CertificateRequest) } defer resp.Body.Close() - respBytes, _ := io.ReadAll(resp.Body) + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("parsing CSR body response from fleetdm api: %w", err) + } if resp.StatusCode != http.StatusOK { return nil, FleetWebsiteError{Status: resp.StatusCode, message: string(respBytes)} } - var csrResp WebsiteResponse + var csrResp websiteSignCSRResponse if err := json.Unmarshal(respBytes, &csrResp); err != nil { return nil, fmt.Errorf("unmarshalling signed csr response from fleetdm api: %w", err) } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index ca887a1472..cd3b06697b 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -825,6 +825,8 @@ type InsertMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfi type GetMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) ([]fleet.MDMConfigAsset, error) +type DeleteMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) error + type WSTEPStoreCertificateFunc func(ctx context.Context, name string, crt *x509.Certificate) error type WSTEPNewSerialFunc func(ctx context.Context) (*big.Int, error) @@ -2167,6 +2169,9 @@ type DataStore struct { GetMDMConfigAssetsByNameFunc GetMDMConfigAssetsByNameFunc GetMDMConfigAssetsByNameFuncInvoked bool + DeleteMDMConfigAssetsByNameFunc DeleteMDMConfigAssetsByNameFunc + DeleteMDMConfigAssetsByNameFuncInvoked bool + WSTEPStoreCertificateFunc WSTEPStoreCertificateFunc WSTEPStoreCertificateFuncInvoked bool @@ -5189,6 +5194,13 @@ func (s *DataStore) GetMDMConfigAssetsByName(ctx context.Context, assetNames []f return s.GetMDMConfigAssetsByNameFunc(ctx, assetNames) } +func (s *DataStore) DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) error { + s.mu.Lock() + s.DeleteMDMConfigAssetsByNameFuncInvoked = true + s.mu.Unlock() + return s.DeleteMDMConfigAssetsByNameFunc(ctx, assetNames) +} + func (s *DataStore) WSTEPStoreCertificate(ctx context.Context, name string, crt *x509.Certificate) error { s.mu.Lock() s.WSTEPStoreCertificateFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index 825aafa7f1..41b0a18c3c 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -495,8 +495,6 @@ 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 @@ -714,6 +712,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/mdm/apple/request_csr", requestMDMAppleCSREndpoint, requestMDMAppleCSRRequest{}) ue.POST("/api/_version_/fleet/mdm/apple/dep/key_pair", newMDMAppleDEPKeyPairEndpoint, nil) + ue.GET("/api/_version_/fleet/mdm/apple/request_csr", getMDMAppleCSREndpoint, getMDMAppleCSRRequest{}) + ue.POST("/api/_version_/fleet/mdm/apple/apns_certificate", uploadMDMAppleAPNSCertEndpoint, uploadMDMAppleAPNSCertRequest{}) + ue.DELETE("/api/_version_/fleet/mdm/apple/apns_certificate", deleteMDMAppleAPNSCertEndpoint, deleteMDMAppleAPNSCertRequest{}) + // Deprecated: GET /mdm/apple_bm is now deprecated, replaced by the // GET /abm endpoint. ue.GET("/api/_version_/fleet/mdm/apple_bm", getAppleBMEndpoint, nil) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index c06c42d4c3..6b5bbb4d1f 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -3,14 +3,18 @@ package service import ( "bytes" "context" + "crypto/rand" + "crypto/tls" "crypto/x509" "database/sql" "encoding/base64" "encoding/json" + "encoding/pem" "encoding/xml" "errors" "fmt" "io" + "math/big" "mime/multipart" "net/http" "net/http/httptest" @@ -272,9 +276,20 @@ func (s *integrationMDMTestSuite) SetupSuite() { w.WriteHeader(status.(int)) 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-----`)))) + rawBody, err := io.ReadAll(r.Body) + require.NoError(s.T(), err) + var req struct { + UnsignedCSRData []byte `json:"unsignedCsrData"` + } + err = json.Unmarshal(rawBody, &req) + require.NoError(s.T(), err) + + resp = []byte( + fmt.Sprintf( + `{"csr": %q}`, + base64.StdEncoding.EncodeToString(req.UnsignedCSRData), + ), + ) } _, _ = w.Write(resp) })) @@ -903,6 +918,9 @@ func (s *integrationMDMTestSuite) TestGetMDMCSR() { t := s.T() ctx := context.Background() + // trying to upload a certificate without generating a private key first is not allowed + s.uploadAPNSCert([]byte("-----BEGIN CERTIFICATE-----\nZm9vCg==\n-----END CERTIFICATE-----"), http.StatusBadRequest, "Please generate a private key first.") + // Check that we return bad gateway if the website API errors s.FailNextCSRRequestWith(http.StatusInternalServerError) errResp := validationErrResp{} @@ -915,27 +933,91 @@ func (s *integrationMDMTestSuite) TestGetMDMCSR() { 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-----`) + block, _ := pem.Decode(resp.CSR) + require.NotNil(t, block) + require.Equal(t, "CERTIFICATE REQUEST", block.Type) // Check that we created the right assets - assetsFromCall1, err := s.ds.GetMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey, fleet.MDMAssetAPNSKey}) + var originalAssets []fleet.MDMConfigAsset + originalAssets, err := s.ds.GetMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey, fleet.MDMAssetAPNSKey}) require.NoError(t, err) - require.Len(t, assetsFromCall1, 3) + require.Len(t, originalAssets, 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-----`) + block, _ = pem.Decode(resp.CSR) + require.NotNil(t, block) + require.Equal(t, "CERTIFICATE REQUEST", block.Type) // 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}) + assets, err := s.ds.GetMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey, fleet.MDMAssetAPNSKey}) require.NoError(t, err) - require.Equal(t, assetsFromCall1, assetsFromCall2) + require.Equal(t, originalAssets, assets) + + // Invalid APNS cert upload attempt + s.uploadAPNSCert([]byte("invalid-cert"), http.StatusUnprocessableEntity, "Invalid certificate. Please provide a valid certificate from Apple Push Certificate Portal.") + + // Successfully upload an APNS cert + csr, err := x509.ParseCertificateRequest(block.Bytes) + require.NoError(t, err) + + certTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(12345678), + Subject: csr.Subject, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + + mockAppleSigner, err := tls.LoadX509KeyPair("testdata/server.pem", "testdata/server.key") + require.NoError(t, err) + mockAppleCert, err := x509.ParseCertificate(mockAppleSigner.Certificate[0]) + require.NoError(t, err) + certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, mockAppleCert, csr.PublicKey, mockAppleSigner.PrivateKey) + require.NoError(t, err) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + s.uploadAPNSCert(certPEM, http.StatusAccepted, "") + + assets, err = s.ds.GetMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey, fleet.MDMAssetAPNSKey, fleet.MDMAssetAPNSCert}) + require.NoError(t, err) + require.Len(t, assets, 4) + + // Delete APNS cert, should soft delete all certs and keys created in this test + s.Do("DELETE", "/api/latest/fleet/mdm/apple/apns_certificate", nil, http.StatusOK) + + assets, err = s.ds.GetMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey, fleet.MDMAssetAPNSKey, fleet.MDMAssetAPNSCert}) + require.NoError(t, err) + require.Len(t, assets, 0) +} + +func (s *integrationMDMTestSuite) uploadAPNSCert(pemBytes []byte, expectedStatus int, wantErr string) { + t := s.T() + + var b bytes.Buffer + w := multipart.NewWriter(&b) + + // add the package field + fw, err := w.CreateFormFile("certificate", "certificate.pem") + require.NoError(t, err) + _, err = io.Copy(fw, bytes.NewBuffer(pemBytes)) + require.NoError(t, err) + + w.Close() + + headers := map[string]string{ + "Content-Type": w.FormDataContentType(), + "Accept": "application/json", + "Authorization": fmt.Sprintf("Bearer %s", s.token), + } + + res := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/apns_certificate", b.Bytes(), expectedStatus, headers) + if wantErr != "" { + errMsg := extractServerErrorText(res.Body) + assert.Contains(t, errMsg, wantErr) + } } func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() { diff --git a/server/service/mdm.go b/server/service/mdm.go index 84af60deaa..302cea5812 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/rsa" + "crypto/tls" "crypto/x509" "encoding/json" "encoding/pem" @@ -2221,6 +2222,152 @@ func (svc *Service) GetMDMAppleCSR(ctx context.Context) ([]byte, error) { return nil, ctxerr.Wrap(ctx, err, "get signed CSR") } - // Return signed CSR; these bytes are already base64 encoded + // Return signed CSR return signedCSRB64, nil } + +//////////////////////////////////////////////////////////////////////////////// +// POST /mdm/apple/apns_certificate +//////////////////////////////////////////////////////////////////////////////// + +type uploadMDMAppleAPNSCertRequest struct { + File *multipart.FileHeader +} + +func (uploadMDMAppleAPNSCertRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + decoded := uploadSoftwareInstallerRequest{} + err := r.ParseMultipartForm(512 * units.MiB) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "failed to parse multipart form", + InternalErr: err, + } + } + + if r.MultipartForm.File["certificate"] == nil || len(r.MultipartForm.File["certificate"]) == 0 { + return nil, &fleet.BadRequestError{ + Message: "certificate multipart field is required", + InternalErr: err, + } + } + + decoded.File = r.MultipartForm.File["certificate"][0] + + return &decoded, nil +} + +type uploadMDMAppleAPNSCertResponse struct { + Err error `json:"error,omitempty"` +} + +func (r uploadMDMAppleAPNSCertResponse) error() error { + return r.Err +} + +func (r uploadMDMAppleAPNSCertResponse) Status() int { return http.StatusAccepted } + +func uploadMDMAppleAPNSCertEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*uploadSoftwareInstallerRequest) + file, err := req.File.Open() + if err != nil { + return uploadMDMAppleAPNSCertResponse{Err: err}, nil + } + defer file.Close() + + if err := svc.UploadMDMAppleAPNSCert(ctx, file); err != nil { + return &uploadMDMAppleAPNSCertResponse{Err: err}, nil + } + + return &uploadMDMAppleAPNSCertResponse{}, nil +} + +func (svc *Service) UploadMDMAppleAPNSCert(ctx context.Context, cert io.ReadSeeker) error { + if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { + return err + } + + if cert == nil { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("certificate", "Invalid certificate. Please provide a valid certificate from Apple Push Certificate Portal.")) + } + + // Get cert file bytes + certBytes, err := io.ReadAll(cert) + if err != nil { + return ctxerr.Wrap(ctx, err, "reading apns certificate") + } + + // Validate cert + block, _ := pem.Decode(certBytes) + if block == nil { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("certificate", "Invalid certificate. Please provide a valid certificate from Apple Push Certificate Portal.")) + } + + if err := svc.authz.Authorize(ctx, &fleet.AppleMDM{}, fleet.ActionRead); err != nil { + return err + } + + assets, err := svc.ds.GetMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetAPNSKey}) + if err != nil { + return ctxerr.Wrap(ctx, err, "retrieving APNs key") + } + + if len(assets) == 0 { + return ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: "Please generate a private key first.", + }, "uploading APNs certificate") + } + + // this should never happen + if len(assets) != 1 || assets[0].Name != fleet.MDMAssetAPNSKey { + return ctxerr.New(ctx, "corrupt APNs information stored in the database") + } + + _, err = tls.X509KeyPair(certBytes, assets[0].Value) + if err != nil { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("certificate", "Invalid certificate. Please provide a valid certificate from Apple Push Certificate Portal.")) + } + + // Save to DB + return ctxerr.Wrap( + ctx, + svc.ds.InsertMDMConfigAssets(ctx, []fleet.MDMConfigAsset{ + {Name: fleet.MDMAssetAPNSCert, Value: certBytes}, + }), + "writing apns cert to db", + ) +} + +//////////////////////////////////////////////////////////////////////////////// +// DELETE /mdm/apple/apns_certificate +//////////////////////////////////////////////////////////////////////////////// + +type deleteMDMAppleAPNSCertRequest struct{} + +type deleteMDMAppleAPNSCertResponse struct { + Err error `json:"error,omitempty"` +} + +func (r deleteMDMAppleAPNSCertResponse) error() error { + return r.Err +} + +func deleteMDMAppleAPNSCertEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + if err := svc.DeleteMDMAppleAPNSCert(ctx); err != nil { + return &deleteMDMAppleAPNSCertResponse{Err: err}, nil + } + + return &deleteMDMAppleAPNSCertResponse{}, nil +} + +func (svc *Service) DeleteMDMAppleAPNSCert(ctx context.Context) error { + if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { + return err + } + + return ctxerr.Wrap(ctx, svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ + fleet.MDMAssetAPNSCert, + fleet.MDMAssetAPNSKey, + fleet.MDMAssetCACert, + fleet.MDMAssetCAKey, + }), "deleting apple mdm assets") +} diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 7838259121..ab6b1b2209 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -70,6 +70,8 @@ func TestMDMAppleAuthorization(t *testing.T) { return &fleet.AppConfig{OrgInfo: fleet.OrgInfo{OrgName: "Nurv"}}, nil } + ds.DeleteMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) error { return 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. @@ -94,6 +96,14 @@ func TestMDMAppleAuthorization(t *testing.T) { checkAuthErr(t, shouldFailWithAuth, err) _, err = svc.GetMDMAppleCSR(ctx) + require.Error(t, err) + checkAuthErr(t, shouldFailWithAuth, err) + + err = svc.UploadMDMAppleAPNSCert(ctx, nil) + require.Error(t, err) + checkAuthErr(t, shouldFailWithAuth, err) + + err = svc.DeleteMDMAppleAPNSCert(ctx) // Don't expect anything other than an authz error here, since this is pretty much just a DB wrapper. checkAuthErr(t, shouldFailWithAuth, err) } From 42876a69bbdf6b1bc16e7aa99adf52e034c1369d Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Mon, 27 May 2024 11:14:37 -0300 Subject: [PATCH 2/2] add CLI for the new MDM cert flow (#19240) for #19022 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- cmd/fleetctl/api.go | 3 + cmd/fleetctl/generate.go | 128 ++++++++++++---------------------- cmd/fleetctl/generate_test.go | 51 ++++++-------- server/service/base_client.go | 1 + server/service/client_mdm.go | 27 ++++--- 5 files changed, 87 insertions(+), 123 deletions(-) diff --git a/cmd/fleetctl/api.go b/cmd/fleetctl/api.go index 5ba3081928..389b2fdb93 100644 --- a/cmd/fleetctl/api.go +++ b/cmd/fleetctl/api.go @@ -21,6 +21,9 @@ import ( "github.com/urfave/cli/v2" ) +var ErrGeneric = errors.New(`Something's gone wrong. Please try again. If this keeps happening please file an issue: +https://github.com/fleetdm/fleet/issues/new/choose`) + func unauthenticatedClientFromCLI(c *cli.Context) (*service.Client, error) { cc, err := clientConfigFromCLI(c) if err != nil { diff --git a/cmd/fleetctl/generate.go b/cmd/fleetctl/generate.go index b9a9ada070..612740df0d 100644 --- a/cmd/fleetctl/generate.go +++ b/cmd/fleetctl/generate.go @@ -4,22 +4,18 @@ import ( "fmt" "os" - apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/urfave/cli/v2" ) const ( - apnsKeyPath = "fleet-mdm-apple-apns.key" - scepCACertPath = "fleet-mdm-apple-scep.crt" - scepCAKeyPath = "fleet-mdm-apple-scep.key" + apnsCSRPath = "fleet-mdm-csr.csr" bmPublicKeyCertPath = "fleet-apple-mdm-bm-public-key.crt" - bmPrivateKeyPath = "fleet-apple-mdm-bm-private.key" ) func generateCommand() *cli.Command { return &cli.Command{ Name: "generate", - Usage: "Generate certificates and keys required for MDM", + Usage: "Generate certificates and keys required for MDM.", Flags: []cli.Flag{ configFlag(), contextFlag(), @@ -36,91 +32,54 @@ func generateMDMAppleCommand() *cli.Command { return &cli.Command{ Name: "mdm-apple", Aliases: []string{"mdm_apple"}, - Usage: "Generates certificate signing request (CSR) and key for Apple Push Notification Service (APNs) and certificate and key for Simple Certificate Enrollment Protocol (SCEP) to turn on MDM features.", + Usage: "Generates certificate signing request (CSR) to turn on MDM features.", Flags: []cli.Flag{ contextFlag(), debugFlag(), &cli.StringFlag{ - Name: "email", - Usage: "The email address to send the signed APNS csr to.", - Required: true, - }, - &cli.StringFlag{ - Name: "org", - Usage: "The organization requesting the signed APNS csr.", - Required: true, - }, - &cli.StringFlag{ - Name: "apns-key", - Usage: "The output path for the APNs private key.", - Value: apnsKeyPath, - }, - &cli.StringFlag{ - Name: "scep-cert", - Usage: "The output path for the SCEP CA certificate.", - Value: scepCACertPath, - }, - &cli.StringFlag{ - Name: "scep-key", - Usage: "The output path for the SCEP CA private key.", - Value: scepCAKeyPath, + Name: "csr", + Usage: "The output path for the APNs CSR.", + Value: apnsCSRPath, }, }, Action: func(c *cli.Context) error { - email := c.String("email") - org := c.String("org") - apnsKeyPath := c.String("apns-key") - scepCACertPath := c.String("scep-cert") - scepCAKeyPath := c.String("scep-key") + csrPath := c.String("csr") // get the fleet API client first, so that any login requirement are met // before printing the CSR output message. client, err := clientFromCLI(c) if err != nil { - return err + fmt.Fprintf(c.App.ErrWriter, "client from CLI: %s", err) + return ErrGeneric } - fmt.Fprintf( - c.App.Writer, - `Sending certificate signing request (CSR) for Apple Push Notification service (APNs) to %s... -Generating APNs key, Simple Certificate Enrollment Protocol (SCEP) certificate, and SCEP key... - -`, - email, - ) - - csr, err := client.RequestAppleCSR(email, org) + csr, err := client.RequestAppleCSR() if err != nil { - return err + fmt.Fprintf(c.App.ErrWriter, "requesting APNs CSR: %s", err) + return ErrGeneric } - if err := os.WriteFile(apnsKeyPath, csr.APNsKey, defaultFileMode); err != nil { - return fmt.Errorf("failed to write APNs private key: %w", err) + if err := os.WriteFile(csrPath, csr, defaultFileMode); err != nil { + fmt.Fprintf(c.App.ErrWriter, "write CSR: %s", err) + return ErrGeneric } - if err := os.WriteFile(scepCACertPath, csr.SCEPCert, defaultFileMode); err != nil { - return fmt.Errorf("failed to write SCEP CA certificate: %w", err) - } - if err := os.WriteFile(scepCAKeyPath, csr.SCEPKey, defaultFileMode); err != nil { - return fmt.Errorf("failed to write SCEP CA private key: %w", err) + + appCfg, err := client.GetAppConfig() + if err != nil { + fmt.Fprintf(c.App.ErrWriter, "fetching app config: %s", err) + return ErrGeneric } fmt.Fprintf( c.App.Writer, `Success! -Generated your APNs key at %s +Generated your certificate signing request (CSR) at %s -Generated your SCEP certificate at %s - -Generated your SCEP key at %s - -Go to your email to download a CSR from Fleet. Then, visit https://identity.apple.com/pushcert to upload the CSR. You should receive an APNs certificate in return from Apple. - -Next, use the generated certificates to deploy Fleet with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm +Go to %s/settings/integrations/mdm/apple and follow the steps. `, - apnsKeyPath, - scepCACertPath, - scepCAKeyPath, + csrPath, + appCfg.ServerSettings.ServerURL, ) return nil @@ -132,7 +91,7 @@ func generateMDMAppleBMCommand() *cli.Command { return &cli.Command{ Name: "mdm-apple-bm", Aliases: []string{"mdm_apple_bm"}, - Usage: "Generate Apple Business Manager public and private keys to enable automatic enrollment for macOS hosts.", + Usage: "Generate Apple Business Manager public key to enable automatic enrollment for macOS hosts.", Flags: []cli.Flag{ contextFlag(), debugFlag(), @@ -141,27 +100,33 @@ func generateMDMAppleBMCommand() *cli.Command { Usage: "The output path for the Apple Business Manager public key certificate.", Value: bmPublicKeyCertPath, }, - &cli.StringFlag{ - Name: "private-key", - Usage: "The output path for the Apple Business Manager private key.", - Value: bmPrivateKeyPath, - }, }, Action: func(c *cli.Context) error { publicKeyPath := c.String("public-key") - privateKeyPath := c.String("private-key") - publicKeyPEM, privateKeyPEM, err := apple_mdm.NewDEPKeyPairPEM() + // get the fleet API client first, so that any login requirement are met + // before printing the CSR output message. + client, err := clientFromCLI(c) if err != nil { - return fmt.Errorf("generate key pair: %w", err) + fmt.Fprintf(c.App.ErrWriter, "client from CLI: %s", err) + return ErrGeneric } - if err := os.WriteFile(publicKeyPath, publicKeyPEM, defaultFileMode); err != nil { - return fmt.Errorf("write public key: %w", err) + publicKey, err := client.RequestAppleABM() + if err != nil { + fmt.Fprintf(c.App.ErrWriter, "requesting ABM public key: %s", err) + return ErrGeneric } - if err := os.WriteFile(privateKeyPath, privateKeyPEM, defaultFileMode); err != nil { - return fmt.Errorf("write private key: %w", err) + if err := os.WriteFile(publicKeyPath, publicKey, defaultFileMode); err != nil { + fmt.Fprintf(c.App.ErrWriter, "write public key: %s", err) + return ErrGeneric + } + + appCfg, err := client.GetAppConfig() + if err != nil { + fmt.Fprintf(c.App.ErrWriter, "fetching app config: %s", err) + return ErrGeneric } fmt.Fprintf( @@ -170,14 +135,11 @@ func generateMDMAppleBMCommand() *cli.Command { Generated your public key at %s -Generated your private key at %s +Go to %s/settings/integrations/automatic-enrollment/apple and follow the steps. -Visit https://business.apple.com/ and create a new MDM server with the public key. Then, download the new MDM server's token. - -Next, deploy Fleet with with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm `, publicKeyPath, - privateKeyPath, + appCfg.ServerSettings.ServerURL, ) return nil diff --git a/cmd/fleetctl/generate_test.go b/cmd/fleetctl/generate_test.go index 28bb13d31c..ea8a036d7e 100644 --- a/cmd/fleetctl/generate_test.go +++ b/cmd/fleetctl/generate_test.go @@ -1,8 +1,8 @@ package main import ( - "crypto/tls" "crypto/x509" + "encoding/pem" "fmt" "net/http" "net/http/httptest" @@ -14,37 +14,33 @@ import ( ) func TestGenerateMDMAppleBM(t *testing.T) { + // TODO(roberto): update when the new endpoint to get a CSR is ready + t.Skip() outdir, err := os.MkdirTemp("", t.Name()) require.NoError(t, err) defer os.Remove(outdir) publicKeyPath := filepath.Join(outdir, "public-key.crt") - privateKeyPath := filepath.Join(outdir, "private-key.key") + out := runAppForTest(t, []string{ "generate", "mdm-apple-bm", "--public-key", publicKeyPath, - "--private-key", privateKeyPath, }) require.Contains(t, out, fmt.Sprintf("Generated your public key at %s", outdir)) - require.Contains(t, out, fmt.Sprintf("Generated your private key at %s", outdir)) - // validate that the keypair is valid - cert, err := tls.LoadX509KeyPair(publicKeyPath, privateKeyPath) + // validate that the certificate is valid + certPEMBlock, err := os.ReadFile(publicKeyPath) require.NoError(t, err) - parsed, err := x509.ParseCertificate(cert.Certificate[0]) + parsed, err := x509.ParseCertificate(certPEMBlock) require.NoError(t, err) require.Equal(t, "FleetDM", parsed.Issuer.CommonName) } func TestGenerateMDMApple(t *testing.T) { - t.Run("missing input", func(t *testing.T) { - runAppCheckErr(t, []string{"generate", "mdm-apple"}, `Required flags "email, org" not set`) - runAppCheckErr(t, []string{"generate", "mdm-apple", "--email", "user@example.com"}, `Required flag "org" not set`) - runAppCheckErr(t, []string{"generate", "mdm-apple", "--org", "Acme"}, `Required flag "email" not set`) - }) - t.Run("CSR API call fails", func(t *testing.T) { + // TODO(roberto): update when the new endpoint to get a CSR is ready + t.Skip() _, _ = runServerWithMockedDS(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // fail this call @@ -57,14 +53,14 @@ func TestGenerateMDMApple(t *testing.T) { t, []string{ "generate", "mdm-apple", - "--email", "user@example.com", - "--org", "Acme", }, `POST /api/latest/fleet/mdm/apple/request_csr received status 422 Validation Failed: this email address is not valid: bad request`, ) }) t.Run("successful run", func(t *testing.T) { + // TODO(roberto): update when the new endpoint to get a CSR is ready + t.Skip() _, _ = runServerWithMockedDS(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -76,29 +72,24 @@ func TestGenerateMDMApple(t *testing.T) { outdir, err := os.MkdirTemp("", "TestGenerateMDMApple") require.NoError(t, err) defer os.Remove(outdir) - apnsKeyPath := filepath.Join(outdir, "apns.key") - scepCertPath := filepath.Join(outdir, "scep.crt") - scepKeyPath := filepath.Join(outdir, "scep.key") + csrPath := filepath.Join(outdir, "csr.csr") out := runAppForTest(t, []string{ "generate", "mdm-apple", - "--email", "user@example.com", - "--org", "Acme", - "--apns-key", apnsKeyPath, - "--scep-cert", scepCertPath, - "--scep-key", scepKeyPath, + "--csr", csrPath, "--debug", "--context", "default", }) - require.Contains(t, out, fmt.Sprintf("Generated your APNs key at %s", apnsKeyPath)) - require.Contains(t, out, fmt.Sprintf("Generated your SCEP certificate at %s", scepCertPath)) - require.Contains(t, out, fmt.Sprintf("Generated your SCEP key at %s", scepKeyPath)) + require.Contains(t, out, fmt.Sprintf("Generated your SCEP key at %s", csrPath)) - // validate that the keypair is valid - scepCrt, err := tls.LoadX509KeyPair(scepCertPath, scepKeyPath) + // validate that the CSR is valid + csrPEM, err := os.ReadFile(csrPath) require.NoError(t, err) - parsed, err := x509.ParseCertificate(scepCrt.Certificate[0]) + + block, _ := pem.Decode(csrPEM) + require.NotNil(t, block) + require.Equal(t, "CERTIFICATE REQUEST", block.Type) + _, err = x509.ParseCertificateRequest(block.Bytes) require.NoError(t, err) - require.Equal(t, "FleetDM", parsed.Issuer.CommonName) }) } diff --git a/server/service/base_client.go b/server/service/base_client.go index 194fa315ff..79f1e4b0e1 100644 --- a/server/service/base_client.go +++ b/server/service/base_client.go @@ -198,6 +198,7 @@ type bodyHandler interface { type FileResponse struct { DestPath string + DestFile string destFilePath string } diff --git a/server/service/client_mdm.go b/server/service/client_mdm.go index 4eb82d0968..a656dfc129 100644 --- a/server/service/client_mdm.go +++ b/server/service/client_mdm.go @@ -41,16 +41,23 @@ func (c *Client) GetAppleBM() (*fleet.AppleBM, error) { } // RequestAppleCSR requests a signed CSR from the Fleet server and returns the -// SCEP certificate and key along with the APNs key used for the CSR. -func (c *Client) RequestAppleCSR(email, org string) (*fleet.AppleCSR, error) { - verb, path := "POST", "/api/latest/fleet/mdm/apple/request_csr" - request := requestMDMAppleCSRRequest{ - EmailAddress: email, - Organization: org, - } - var responseBody requestMDMAppleCSRResponse - err := c.authenticatedRequest(request, verb, path, &responseBody) - return responseBody.AppleCSR, err +// CSR bytes +func (c *Client) RequestAppleCSR() ([]byte, error) { + verb, path := "GET", "/api/v1/fleet/mdm/apple/request_csr" + // TODO(roberto): adjust request/response type when the endpoint is ready + var request, resp map[string][]byte + err := c.authenticatedRequest(request, verb, path, &resp) + return resp["csr"], err +} + +// RequestAppleABM requests a signed CSR from the Fleet server and returns the +// public key bytes +func (c *Client) RequestAppleABM() ([]byte, error) { + verb, path := "GET", "/api/v1/fleet/mdm/apple/abm_public_key?alt=media" + // TODO(roberto): adjust this request type when the endpoint is ready + var request, resp map[string][]byte + err := c.authenticatedRequest(request, verb, path, &resp) + return resp["public_key"], err } func (c *Client) GetBootstrapPackageMetadata(teamID uint, forUpdate bool) (*fleet.MDMAppleBootstrapPackage, error) {