Add endpoint to trigger CSR request for APNs on fleetdm.com (#9494)

This commit is contained in:
Martin Angers 2023-01-25 14:44:29 -05:00 committed by GitHub
parent f095431f12
commit d0e6891d10
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 274 additions and 67 deletions

5
.gitignore vendored
View file

@ -75,3 +75,8 @@ test_tuf
# Residual files when running the msrc generate command
msrc_in/
msrc_out/
# Keys and certificates that may be generated in the root of the repo
# (e.g. with ./build/fleetctl generate ...).
/*.key
/*.crt

View file

@ -0,0 +1 @@
* Added the `POST /api/v1/fleet/mdm/apple/request_csr` endpoint to trigger a Certificate Signing Request to fleetdm.com and return the associated APNs private key and SCEP certificate and key.

View file

@ -9,9 +9,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/service"
@ -108,6 +106,13 @@ func generateMDMAppleCommand() *cli.Command {
scepCACertPath := c.String("scep-cert")
scepCAKeyPath := c.String("scep-key")
// 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.Writer,
`Sending certificate signing request (CSR) for Apple Push Notification service (APNs) to %s...
@ -117,45 +122,21 @@ Generating APNs key, Simple Certificate Enrollment Protocol (SCEP) certificate,
email,
)
// create apns csr and send to fleetdm.com
apnsCSR, apnsKey, err := apple_mdm.GenerateAPNSCSRKey(email, org)
csr, err := client.RequestAppleCSR(email, org)
if err != nil {
return fmt.Errorf("generate apns csr: %w", err)
return err
}
apnsKeyPEM := apple_mdm.EncodePrivateKeyPEM(apnsKey)
err = os.WriteFile(apnsKeyPath, apnsKeyPEM, 0600)
if err != nil {
return fmt.Errorf("write private key: %w", err)
if err := os.WriteFile(apnsKeyPath, csr.APNsKey, 0600); err != nil {
return fmt.Errorf("failed to write APNs private key: %w", err)
}
if err := os.WriteFile(scepCACertPath, csr.SCEPCert, 0600); err != nil {
return fmt.Errorf("failed to write SCEP CA certificate: %w", err)
}
if err := os.WriteFile(scepCAKeyPath, csr.SCEPKey, 0600); err != nil {
return fmt.Errorf("failed to write SCEP CA private key: %w", err)
}
client := fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second))
err = apple_mdm.GetSignedAPNSCSR(client, apnsCSR)
if err != nil {
return fmt.Errorf("get signed apns csr: %w", err)
}
// init scep ca
scepCACert, scepCAKey, err := apple_mdm.NewSCEPCACertKey()
if err != nil {
return fmt.Errorf("init scep CA: %w", err)
}
scepCACertPEM := apple_mdm.EncodeCertPEM(scepCACert)
err = os.WriteFile(scepCACertPath, scepCACertPEM, 0600)
if err != nil {
return fmt.Errorf("write scep ca certificate: %w", err)
}
scepCAKeyPEM := apple_mdm.EncodePrivateKeyPEM(scepCAKey)
err = os.WriteFile(scepCAKeyPath, scepCAKeyPEM, 0600)
if err != nil {
return fmt.Errorf("write scep ca private key: %w", err)
}
// TODO: update text once https://github.com/fleetdm/fleet/issues/8595 is complete. Consider linking to specific configuration section.
fmt.Fprintf(
c.App.Writer,
`Success!

View file

@ -529,6 +529,8 @@ The MDM endpoints exist to support the related command-line interface sub-comman
- [Get Apple MDM](#get-apple-mdm)
- [Get Apple BM](#get-apple-bm)
- [Unenroll host from Fleet MDM](#unenroll-host-from-fleet-mdm)
- [Generate Apple DEP Key Pair](#generate-apple-dep-key-pair)
- [Request Certificate Signing Request (CSR)](#request-certificate-signing-request-csr)
### Get Apple MDM
@ -623,6 +625,33 @@ None.
Note that the `public_key` and `private_key` are base64 encoded and should be decoded before writing them to files.
### Request Certificate Signing Request (CSR)
`POST /api/v1/fleet/mdm/apple/request_csr`
#### Parameters
| Name | Type | In | Description |
| ---- | ------- | ---- | --------------------------------------- |
| email_address | string | body | **Required.** The email that will be associated with the Apple APNs certificate. |
| organization | string | body | **Required.** The name of the organization associated with the Apple APNs certificate. |
#### Example
`POST /api/v1/fleet/mdm/apple/request_csr`
##### Default response
```
{
"apns_key": "aGV5LCBJJ20gc2VjcmV0Cg==",
"scep_cert": "bHR5LCBJJ20gc2VjcmV0Cg=",
"scep_key": "lKT5LCBJJ20gc2VjcmV0Cg="
}
```
Note that the response fields are base64 encoded and should be decoded before writing them to files. Once base64-decoded, they are PEM-encoded certificate and keys.
## Get or apply configuration files
These API routes are used by the `fleetctl` CLI tool. Users can manage Fleet with `fleetctl` and [configuration files in YAML syntax](https://fleetdm.com/docs/using-fleet/configuration-files/).

View file

@ -43,6 +43,7 @@ Users with the Admin role receive all permissions.
| Retrieve contents from file carving | | | ✅ |
| View Apple mobile device management (MDM) certificate information | | | ✅ |
| View Apple business manager (BM) information | | | ✅ |
| Generate Apple mobile device management (MDM) certificate signing request (CSR) | | | ✅ |

View file

@ -33,6 +33,17 @@ func (a AppleBM) AuthzType() string {
return "mdm_apple"
}
type AppleCSR struct {
// NOTE: []byte automatically JSON-encodes as a base64-encoded string
APNsKey []byte `json:"apns_key"`
SCEPCert []byte `json:"scep_cert"`
SCEPKey []byte `json:"scep_key"`
}
func (a AppleCSR) AuthzType() string {
return "mdm_apple"
}
// AppConfigUpdated is the minimal interface required to get and update the
// AppConfig, as required to handle the DEP API errors to flag that Apple's
// terms have changed and must be accepted. The Fleet Datastore satisfies

View file

@ -542,6 +542,7 @@ type Service interface {
GetAppleMDM(ctx context.Context) (*AppleMDM, error)
GetAppleBM(ctx context.Context) (*AppleBM, error)
RequestMDMAppleCSR(ctx context.Context, email, org string) (*AppleCSR, error)
// NewMDMAppleEnrollmentProfile creates and returns new enrollment profile.
// Such enrollment profiles allow devices to enroll to Fleet MDM.

View file

@ -11,23 +11,24 @@ import (
"io/ioutil"
"net/http"
"os"
"strings"
"github.com/micromdm/nanodep/tokenpki"
"github.com/micromdm/scep/v2/depot"
)
const defaultFleetDMAPIURL = "https://fleetdm.com"
const getSignedAPNSCSRPath = "/api/v1/get_signed_apns_csr"
const depCertificateCommonName = "FleetDM"
const depCertificateExpiryDays = 30
const (
defaultFleetDMAPIURL = "https://fleetdm.com"
getSignedAPNSCSRPath = "/api/v1/deliver-apple-csr"
depCertificateCommonName = "FleetDM"
depCertificateExpiryDays = 30
)
// emailAddressOID defined by https://oidref.com/1.2.840.113549.1.9.1
var emailAddressOID = []int{1, 2, 840, 113549, 1, 9, 1}
// GenerateAPNSCSRKey generates a APNS csr to be sent to fleetdm.com and returns a csr and key.
// GenerateAPNSCSRKey generates a APNS CSR (certificate signing request) and
// returns the CSR and private key.
func GenerateAPNSCSRKey(email, org string) (*x509.CertificateRequest, *rsa.PrivateKey, error) {
key, err := newPrivateKey()
if err != nil {
@ -60,16 +61,16 @@ func GenerateAPNSCSRKey(email, org string) (*x509.CertificateRequest, *rsa.Priva
}
type getSignedAPNSCSRRequest struct {
// CSR is the pem encoded certificate request.
CSR []byte `json:"csr"`
UnsignedCSRData []byte `json:"unsignedCsrData"`
}
// GetSignedAPNSCSR makes a request to the fleetdm.com API to get a signed apns csr that is sent to the email provided in the certificate subject.
// GetSignedAPNSCSR makes a request to the fleetdm.com API to get a signed APNs
// CSR that is sent to the email provided in the certificate subject.
func GetSignedAPNSCSR(client *http.Client, csr *x509.CertificateRequest) error {
csrPEM := EncodeCertRequestPEM(csr)
payload := getSignedAPNSCSRRequest{
CSR: csrPEM,
UnsignedCSRData: csrPEM,
}
b, err := json.Marshal(payload)
@ -80,9 +81,8 @@ func GetSignedAPNSCSR(client *http.Client, csr *x509.CertificateRequest) error {
// for testing
baseURL := defaultFleetDMAPIURL
if x := os.Getenv("TEST_FLEETDM_API_URL"); x != "" {
baseURL = x
baseURL = strings.TrimRight(x, "/")
}
u := baseURL + getSignedAPNSCSRPath
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b))
@ -100,11 +100,11 @@ func GetSignedAPNSCSR(client *http.Client, csr *x509.CertificateRequest) error {
b, _ := ioutil.ReadAll(resp.Body)
return fmt.Errorf("api responded with %d: %s", resp.StatusCode, string(b))
}
return nil
}
// NewSCEPCACertKey creates a self-signed CA certificate for use with SCEP and returns the certificate and its private key.
// 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) {
key, err := newPrivateKey()
if err != nil {

View file

@ -17,3 +17,16 @@ func (c *Client) GetAppleBM() (*fleet.AppleBM, error) {
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, "")
return responseBody.AppleBM, err
}
// 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
}

View file

@ -440,6 +440,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
}
ue.GET("/api/_version_/fleet/mdm/apple", getAppleMDMEndpoint, nil)
ue.GET("/api/_version_/fleet/mdm/apple_bm", getAppleBMEndpoint, nil)
// this endpoint must always be accessible (even if MDM is not configured) as
// it bootstraps the setup of MDM (generates CSR request for APNs and SCEP).
ue.POST("/api/_version_/fleet/mdm/apple/request_csr", requestMDMAppleCSREndpoint, requestMDMAppleCSRRequest{})
errorLimiter := ratelimit.NewErrorMiddleware(limitStore)

View file

@ -5871,13 +5871,7 @@ func (s *integrationTestSuite) TestHostsReportDownload() {
require.NoError(t, s.ds.SetOrUpdateHostDisksSpace(ctx, hosts[1].ID, 3.0, 4.0))
res := s.DoRaw("GET", "/api/latest/fleet/hosts/report", nil, http.StatusUnsupportedMediaType, "format", "gzip")
var errs struct {
Message string `json:"message"`
Errors []struct {
Name string `json:"name"`
Reason string `json:"reason"`
} `json:"errors"`
}
var errs validationErrResp
require.NoError(t, json.NewDecoder(res.Body).Decode(&errs))
res.Body.Close()
require.Len(t, errs.Errors, 1)
@ -6260,8 +6254,9 @@ func (s *integrationTestSuite) TestPingEndpoints() {
}
func (s *integrationTestSuite) TestAppleMDMNotConfigured() {
var resp getAppleMDMResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple", nil, http.StatusNotFound, &resp)
var rawResp json.RawMessage
s.DoJSON("GET", "/api/latest/fleet/mdm/apple", nil, http.StatusNotFound, &rawResp)
s.DoJSON("GET", "/api/latest/fleet/mdm/apple_bm", nil, http.StatusPaymentRequired, &rawResp) //premium only
}
func (s *integrationTestSuite) TestOrbitConfigNotifications() {
@ -6336,6 +6331,14 @@ func (s *integrationTestSuite) TestAPIVersion_v1_2022_04() {
s.DoJSON("DELETE", fmt.Sprintf("/api/v1/fleet/global/schedule/%d", createResp.Scheduled.ID), nil, http.StatusOK, &delResp)
}
type validationErrResp struct {
Message string `json:"message"`
Errors []struct {
Name string `json:"name"`
Reason string `json:"reason"`
} `json:"errors"`
}
func createOrbitEnrolledHost(t *testing.T, os, suffix string, ds fleet.Datastore) *fleet.Host {
name := t.Name() + suffix
h, err := ds.NewHost(context.Background(), &fleet.Host{

View file

@ -1835,10 +1835,9 @@ func (s *integrationEnterpriseTestSuite) TestListHosts() {
}
func (s *integrationEnterpriseTestSuite) TestAppleMDMNotConfigured() {
var mdmResp getAppleMDMResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple", nil, http.StatusNotFound, &mdmResp)
var bmResp getAppleBMResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple_bm", nil, http.StatusNotFound, &bmResp)
var rawResp json.RawMessage
s.DoJSON("GET", "/api/latest/fleet/mdm/apple", nil, http.StatusNotFound, &rawResp)
s.DoJSON("GET", "/api/latest/fleet/mdm/apple_bm", nil, http.StatusNotFound, &rawResp)
}
func (s *integrationEnterpriseTestSuite) TestGlobalPolicyCreateReadPatch() {

View file

@ -13,6 +13,7 @@ import (
"math/big"
mathrand "math/rand"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
@ -36,6 +37,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"go.mozilla.org/pkcs7"
"go.uber.org/atomic"
)
func TestIntegrationsMDM(t *testing.T) {
@ -47,7 +49,8 @@ func TestIntegrationsMDM(t *testing.T) {
type integrationMDMTestSuite struct {
withServer
suite.Suite
fleetCfg config.FleetConfig
fleetCfg config.FleetConfig
fleetDMFailCSR atomic.Bool
}
func (s *integrationMDMTestSuite) SetupSuite() {
@ -84,6 +87,27 @@ func (s *integrationMDMTestSuite) SetupSuite() {
s.token = s.getTestAdminToken()
s.cachedAdminToken = s.token
s.fleetCfg = fleetCfg
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.fleetDMFailCSR.Swap(false) {
// fail this call
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("bad request"))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
s.T().Setenv("TEST_FLEETDM_API_URL", srv.URL)
s.T().Cleanup(srv.Close)
}
func (s *integrationMDMTestSuite) FailNextCSRRequest() {
s.fleetDMFailCSR.Store(true)
}
func (s *integrationMDMTestSuite) SucceedNextCSRRequest() {
s.fleetDMFailCSR.Store(false)
}
func (s *integrationMDMTestSuite) TearDownTest() {
@ -255,6 +279,43 @@ func (s *integrationMDMTestSuite) TestDeviceMultipleAuthMessages() {
require.Len(s.T(), listHostsRes.Hosts, 1)
}
func (s *integrationMDMTestSuite) TestAppleMDMCSRRequest() {
t := s.T()
var errResp validationErrResp
// missing arguments
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{}, http.StatusUnprocessableEntity, &errResp)
require.Len(t, errResp.Errors, 1)
require.Equal(t, errResp.Errors[0].Name, "email_address")
// invalid email address
errResp = validationErrResp{}
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{EmailAddress: "abc", Organization: "def"}, http.StatusUnprocessableEntity, &errResp)
require.Len(t, errResp.Errors, 1)
require.Equal(t, errResp.Errors[0].Name, "email_address")
// missing organization
errResp = validationErrResp{}
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{EmailAddress: "a@b.c", Organization: ""}, http.StatusUnprocessableEntity, &errResp)
require.Len(t, errResp.Errors, 1)
require.Equal(t, errResp.Errors[0].Name, "organization")
// fleetdm CSR request failed
s.FailNextCSRRequest()
errResp = validationErrResp{}
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{EmailAddress: "a@b.c", Organization: "test"}, http.StatusBadGateway, &errResp)
require.Len(t, errResp.Errors, 1)
require.Contains(t, errResp.Errors[0].Reason, "FleetDM CSR request failed")
var reqCSRResp requestMDMAppleCSRResponse
// fleetdm CSR request succeeds
s.SucceedNextCSRRequest()
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{EmailAddress: "a@b.c", Organization: "test"}, http.StatusOK, &reqCSRResp)
require.Contains(t, string(reqCSRResp.APNsKey), "-----BEGIN RSA PRIVATE KEY-----\n")
require.Contains(t, string(reqCSRResp.SCEPCert), "-----BEGIN CERTIFICATE-----\n")
require.Contains(t, string(reqCSRResp.SCEPKey), "-----BEGIN RSA PRIVATE KEY-----\n")
}
type device struct {
uuid string
serial string

View file

@ -2,10 +2,21 @@ package service
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
)
////////////////////////////////////////////////////////////////////////////////
// GET /mdm/apple
////////////////////////////////////////////////////////////////////////////////
type getAppleMDMResponse struct {
*fleet.AppleMDM
Err error `json:"error,omitempty"`
@ -49,6 +60,10 @@ func (svc *Service) GetAppleMDM(ctx context.Context) (*fleet.AppleMDM, error) {
return appleMDM, nil
}
////////////////////////////////////////////////////////////////////////////////
// GET /mdm/apple_bm
////////////////////////////////////////////////////////////////////////////////
type getAppleBMResponse struct {
*fleet.AppleBM
Err error `json:"error,omitempty"`
@ -72,3 +87,79 @@ func (svc *Service) GetAppleBM(ctx context.Context) (*fleet.AppleBM, error) {
return nil, fleet.ErrMissingLicense
}
////////////////////////////////////////////////////////////////////////////////
// GET /mdm/apple/request_csr
////////////////////////////////////////////////////////////////////////////////
type requestMDMAppleCSRRequest struct {
EmailAddress string `json:"email_address"`
Organization string `json:"organization"`
}
type requestMDMAppleCSRResponse struct {
*fleet.AppleCSR
Err error `json:"error,omitempty"`
}
func (r requestMDMAppleCSRResponse) error() error { return r.Err }
func requestMDMAppleCSREndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*requestMDMAppleCSRRequest)
csr, err := svc.RequestMDMAppleCSR(ctx, req.EmailAddress, req.Organization)
if err != nil {
return requestMDMAppleCSRResponse{Err: err}, nil
}
return requestMDMAppleCSRResponse{
AppleCSR: csr,
}, nil
}
func (svc *Service) RequestMDMAppleCSR(ctx context.Context, email, org string) (*fleet.AppleCSR, error) {
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
return nil, err
}
if err := fleet.ValidateEmail(email); err != nil {
if strings.TrimSpace(email) == "" {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("email_address", "missing email address"))
}
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("email_address", fmt.Sprintf("invalid email address: %v", err)))
}
if strings.TrimSpace(org) == "" {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("organization", "missing organization"))
}
// create the raw SCEP CA cert and key (creating before the CSR signing
// request so that nothing can fail after the request is made, except for the
// network during the response of course)
scepCACert, scepCAKey, err := apple_mdm.NewSCEPCACertKey()
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "generate SCEP CA cert and key")
}
// create the APNs CSR
apnsCSR, apnsKey, err := apple_mdm.GenerateAPNSCSRKey(email, org)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "generate APNs CSR")
}
// request the signed APNs CSR from fleetdm.com
client := fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second))
if err := apple_mdm.GetSignedAPNSCSR(client, apnsCSR); err != nil {
err = fleet.NewUserMessageError(fmt.Errorf("FleetDM CSR request failed: %w", err), http.StatusBadGateway)
return nil, ctxerr.Wrap(ctx, err)
}
// PEM-encode the cert and keys
scepCACertPEM := apple_mdm.EncodeCertPEM(scepCACert)
scepCAKeyPEM := apple_mdm.EncodePrivateKeyPEM(scepCAKey)
apnsKeyPEM := apple_mdm.EncodePrivateKeyPEM(apnsKey)
return &fleet.AppleCSR{
APNsKey: apnsKeyPEM,
SCEPCert: scepCACertPEM,
SCEPKey: scepCAKeyPEM,
}, nil
}

View file

@ -59,6 +59,13 @@ func TestMDMAppleAuthorization(t *testing.T) {
ctx := test.UserContext(ctx, user)
_, err := svc.GetAppleMDM(ctx)
checkAuthErr(t, shouldFailWithAuth, err)
_, err = svc.GetAppleBM(ctx)
checkAuthErr(t, shouldFailWithAuth, err)
// deliberately send invalid args so it doesn't actually generate a CSR
_, 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)
}
// Only global admins can access the endpoints.

View file

@ -25,7 +25,8 @@ module.exports = {
},
invalidEmailDomain: {
description: 'This email address is on a denylist of domains and was not delivered.'
description: 'This email address is on a denylist of domains and was not delivered.',
responseType: 'badRequest'
},
badRequest: {