fleet/server/mdm/apple/cert.go
Victor Lyuboslavsky 0180cc8086
Add SCEP endpoint for host identity. (#30589)
Fixes #30458 

Contributor docs PR: https://github.com/fleetdm/fleet/pull/30651

# Checklist for submitter

- We will add changes file later.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [x] Added/updated automated tests
- Did not do manual QA since the SCEP client I have doesn't support ECC.
Will rely on next subtasks for manual QA.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced Host Identity SCEP (Simple Certificate Enrollment Protocol)
support, enabling secure host identity certificate enrollment and
management.
* Added new API endpoints for Host Identity SCEP, including certificate
issuance and retrieval.
* Implemented MySQL-backed storage and management for host identity SCEP
certificates and serials.
* Added new database tables for storing host identity SCEP certificates
and serial numbers.
* Provided utilities for encoding certificates and keys, and handling
ECDSA public keys.

* **Bug Fixes**
  * None.

* **Tests**
* Added comprehensive integration and unit tests for Host Identity SCEP
functionality, including certificate issuance, validation, and error
scenarios.

* **Chores**
* Updated test utilities to support unique test names and new SCEP
storage options.
* Extended mock datastore and interfaces for new host identity
certificate methods.

* **Documentation**
* Added comments and documentation for new SCEP-related interfaces,
methods, and database schema changes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 11:44:07 -03:00

221 lines
5.5 KiB
Go

package apple_mdm
import (
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
)
const (
defaultFleetDMAPIURL = "https://fleetdm.com"
getSignedAPNSCSRPath = "/api/v1/deliver-apple-csr"
depCertificateCommonName = "Fleet"
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 (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 {
return nil, nil, fmt.Errorf("generate private key: %w", err)
}
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, nil, err
}
certReq, err := x509.ParseCertificateRequest(b)
if err != nil {
return nil, nil, err
}
return certReq, key, nil
}
func GenerateAPNSCSR(org, email string, key crypto.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
}
func (e FleetWebsiteError) Error() string {
if e.message != "" {
return e.message
}
return "Unknown Error"
}
type getSignedAPNSCSRRequest struct {
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.
func GetSignedAPNSCSR(client *http.Client, csr *x509.CertificateRequest) error {
csrPEM := EncodeCertRequestPEM(csr)
payload := getSignedAPNSCSRRequest{
UnsignedCSRData: csrPEM,
}
b, err := json.Marshal(payload)
if err != nil {
return 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
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b))
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return FleetWebsiteError{Status: resp.StatusCode, message: string(b)}
}
return nil
}
type websiteSignCSRResponse 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, 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 websiteSignCSRResponse
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) {
return depot.NewSCEPCACertKey()
}
// NEWDEPKeyPairPEM generates a new public key certificate and private key for downloading the Apple DEP token.
// The public key is returned as a PEM encoded certificate.
func NewDEPKeyPairPEM() ([]byte, []byte, error) {
// Note, Apple doesn't check the expiry
key, cert, err := tokenpki.SelfSignedRSAKeypair(depCertificateCommonName, depCertificateExpiryDays)
if err != nil {
return nil, nil, fmt.Errorf("generate encryption keypair: %w", err)
}
publicKeyPEM := tokenpki.PEMCertificate(cert.Raw)
privateKeyPEM := tokenpki.PEMRSAPrivateKey(key)
return publicKeyPEM, privateKeyPEM, nil
}