fleet/ee/server/service/request_certificate.go
Jordan Montgomery c713ce6a65
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Build binaries / build-binaries (push) Waiting to run
Check automated documentation is up-to-date / check-doc-gen (push) Waiting to run
Deploy Fleet website / build (20.x) (push) Waiting to run
Test latest changes in fleetctl preview / test-preview (ubuntu-latest) (push) Waiting to run
golangci-lint / lint (push) Waiting to run
golangci-lint / lint-incremental (push) Waiting to run
Docker publish / publish (push) Waiting to run
OSSF Scorecard / Validate Gradle wrapper (push) Waiting to run
OSSF Scorecard / Scorecard analysis (push) Waiting to run
Test DB Changes / test-db-changes (push) Waiting to run
Run fleetd-chrome tests / test-fleetd-chrome (ubuntu-latest) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.4.8, integration-mdm) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.4.8, main) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.4.8, mysql) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.4.8, service) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.4.8, vuln) (push) Waiting to run
Go Tests / test-go-nanomdm (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.0.42, service) (push) Waiting to run
Go Tests / test-go-no-db (fast) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.0.42, vuln) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.4.8, fleetctl) (push) Waiting to run
Go Tests / test-go-no-db (scripts) (push) Waiting to run
Go Tests / test-go (mysql:8.0.44, fleetctl) (push) Waiting to run
Go Tests / test-go (mysql:8.0.44, integration-core) (push) Waiting to run
Go Tests / test-go (mysql:8.0.44, integration-enterprise) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.4.8, integration-core) (push) Waiting to run
Go Tests / test-go (mysql:8.0.44, integration-mdm) (push) Waiting to run
Go Tests / test-go (mysql:8.0.44, main) (push) Waiting to run
Go Tests / test-go (mysql:8.0.44, mysql) (push) Waiting to run
Go Tests / test-go (mysql:8.0.44, service) (push) Waiting to run
Go Tests / test-go (mysql:8.0.44, vuln) (push) Waiting to run
Go Tests / test-go (mysql:9.5.0, fleetctl) (push) Waiting to run
Go Tests / test-go (mysql:9.5.0, integration-core) (push) Waiting to run
Go Tests / test-go (mysql:9.5.0, main) (push) Waiting to run
Go Tests / test-go (mysql:9.5.0, mysql) (push) Waiting to run
Go Tests / test-go (mysql:9.5.0, service) (push) Waiting to run
Go Tests / test-go (mysql:9.5.0, vuln) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.0.42, fleetctl) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.0.42, integration-core) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.0.42, integration-enterprise) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.0.42, integration-mdm) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.0.42, main) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.0.42, mysql) (push) Waiting to run
Go Tests / upload-coverage (push) Blocked by required conditions
Go Tests / aggregate-result (push) Blocked by required conditions
JavaScript Tests / test-js (ubuntu-latest) (push) Waiting to run
JavaScript Tests / lint-js (ubuntu-latest) (push) Waiting to run
Test Mock Changes / test-mock-changes (push) Waiting to run
Test native tooling packaging / test-packaging (local, ubuntu-latest) (push) Waiting to run
Test native tooling packaging / test-packaging (remote, ubuntu-latest) (push) Waiting to run
Go Tests / test-go (mysql:9.5.0, integration-enterprise) (push) Waiting to run
Go Tests / test-go-extended-mysql (mysql:8.4.8, integration-enterprise) (push) Waiting to run
Go Tests / test-go (mysql:9.5.0, integration-mdm) (push) Waiting to run
Test packaging / test-packaging (macos-15) (push) Waiting to run
Test packaging / test-packaging (macos-26) (push) Waiting to run
Test packaging / test-packaging (ubuntu-latest) (push) Waiting to run
Test Puppet / test-puppet (push) Waiting to run
Allow returning x509 PEM cert instead of PEM-encoded PKCS7 envelope from request_certificate endpoint (#44541)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #44533 

Adds an option to return a PEM certificate from the request_certificate
endpoint, rather than the PKCS7 envelope an EST server returns. This
allows it to be more easily used in scripts without conversions, at the
(small) cost of among other things dropping the PKCS7 envelope which
could be signed by the server, etc(though the PEM cert itself should
also be)

# 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://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/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), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.


## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually



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

* **New Features**
* The "Request a Certificate" endpoint can optionally return the issued
certificate as a PEM-encoded X.509 CERTIFICATE block instead of a
PEM-encoded PKCS#7 envelope.

* **Tests**
* Added comprehensive tests covering PEM conversion, tolerance for
base64 whitespace/newlines, error handling for malformed PKCS#7, and
multi-certificate envelope cases.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-04 09:51:50 -04:00

305 lines
12 KiB
Go

package service
import (
"context"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/smallstep/pkcs7"
)
// This code largely adapted from fleet/website/api/controllers/get-est-device-certificate.js
func (svc *Service) RequestCertificate(ctx context.Context, p fleet.RequestCertificatePayload) (*string, error) {
auth, authOk := authz.FromContext(ctx)
if !authOk {
// This shouldn't be possible
return nil, &fleet.BadRequestError{Message: "Missing authentication authorization context"}
}
if auth.AuthnMethod() == authz.AuthnHTTPMessageSignature {
// Message Signature auth is not granular, device already checked and authorized in middleware
svc.authz.SkipAuthorization(ctx)
} else if err := svc.authz.Authorize(ctx, &fleet.RequestCertificatePayload{}, fleet.ActionWrite); err != nil {
return nil, err
}
ca, err := svc.ds.GetCertificateAuthorityByID(ctx, p.ID, true)
if err != nil {
return nil, err
}
if ca.Type != string(fleet.CATypeHydrant) && ca.Type != string(fleet.CATypeCustomESTProxy) {
return nil, &fleet.BadRequestError{Message: "This API currently only supports Hydrant and EST Certificate Authorities."}
}
if ca.Type == string(fleet.CATypeHydrant) {
if ca.ClientID == nil {
return nil, &fleet.BadRequestError{Message: "Certificate authority does not have a username configured."}
}
if ca.ClientSecret == nil {
return nil, &fleet.BadRequestError{Message: "Certificate authority does not have a client secret configured."}
}
}
if ca.Type == string(fleet.CATypeCustomESTProxy) {
if ca.Username == nil {
return nil, &fleet.BadRequestError{Message: "Certificate authority does not have a username configured."}
}
if ca.Password == nil {
return nil, &fleet.BadRequestError{Message: "Certificate authority does not have a password configured."}
}
}
certificateRequest, err := svc.parseCSR(ctx, p.CSR)
if err != nil {
svc.logger.ErrorContext(ctx, "Failed to parse CSR during certificate request", "err", err)
return nil, InvalidCSRError{}
}
idpUsername := ""
if p.IDPClientID != nil || p.IDPToken != nil || p.IDPOauthURL != nil {
if p.IDPClientID == nil || p.IDPToken == nil || p.IDPOauthURL == nil {
return nil, &fleet.BadRequestError{Message: "IDP Client ID, Token, and OAuth URL all must be provided, if any are provided when requesting a certificate."}
}
csrEmail, csrUsername, err := svc.extractCSRUserInfo(ctx, certificateRequest)
if err != nil {
svc.logger.ErrorContext(ctx, "CSR did not have expected format for IDP verification", "err", err)
return nil, InvalidCSRError{}
}
introspectionResponse, err := svc.introspectIDPToken(ctx, *p.IDPClientID, *p.IDPToken, *p.IDPOauthURL)
if err != nil {
svc.logger.ErrorContext(ctx, "Failed to introspect IDP token during certificate request", "idp_url", *p.IDPOauthURL, "err", err)
return nil, InvalidIDPTokenError{}
}
if !introspectionResponse.Active {
svc.logger.ErrorContext(ctx, "Failing Certificate Request due to inactive IDP token", "idp_url", *p.IDPOauthURL)
return nil, InvalidIDPTokenError{}
}
// This field is technically optional in the spec though its omittance may indicate an incompatible IDP or setup
if introspectionResponse.Username == nil || len(*introspectionResponse.Username) == 0 {
svc.logger.ErrorContext(ctx, "Failing Certificate Request due to missing username in IDP token introspection response")
return nil, InvalidIDPTokenError{}
}
idpUsername = *introspectionResponse.Username
// the email should either equal the username or include it as a prefix, i.e.
// email=username@example.com and username=username
if !strings.HasPrefix(csrEmail, csrUsername) {
svc.logger.ErrorContext(ctx, "Failing Certificate Request due to mismatch between CSR email and UPN", "csr_email", csrEmail, "csr_upn", csrUsername)
return nil, InvalidCSRError{}
}
if csrEmail != *introspectionResponse.Username {
svc.logger.ErrorContext(ctx, "Failing Certificate Request due to mismatch between CSR email and IDP token username", "csr_email", csrEmail, "idp_username", *introspectionResponse.Username)
// The email in the CSR must match the username from the IDP token introspection
return nil, InvalidIDPTokenError{}
}
}
csrForRequest := strings.ReplaceAll(p.CSR, "-----BEGIN CERTIFICATE REQUEST-----", "")
csrForRequest = strings.ReplaceAll(csrForRequest, "-----END CERTIFICATE REQUEST-----", "")
csrForRequest = strings.ReplaceAll(csrForRequest, "\\n", "")
var estCA fleet.ESTProxyCA
if ca.Type == string(fleet.CATypeHydrant) {
estCA = fleet.ESTProxyCA{
Name: *ca.Name,
URL: *ca.URL,
Username: *ca.ClientID,
Password: *ca.ClientSecret,
}
} else {
estCA = fleet.ESTProxyCA{
Name: *ca.Name,
URL: *ca.URL,
Username: *ca.Username,
Password: *ca.Password,
}
}
certificate, err := svc.estService.GetCertificate(ctx, estCA, csrForRequest) //nolint (staticheck bug)
if err != nil {
svc.logger.ErrorContext(ctx, "EST certificate request failed", "ca_id", ca.ID, "err", err)
// Bad request may seem like a strange error here but there are many cases where a malformed
// CSR can cause this error and Hydrant's API often returns a 5XX error even in these cases
// so it is not always possible to distinguish between an error caused by a bad request or
// an internal CA error.
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("EST certificate request failed: %s", err.Error())}
}
svc.logger.InfoContext(ctx, "Successfully retrieved a certificate from EST", "ca_id", ca.ID, "idp_username", idpUsername)
if p.ReturnPEMCertificate {
pemCert, err := pkcs7EnvelopeToPEM(certificate.Certificate)
if err != nil {
svc.logger.ErrorContext(ctx, "Failed to convert PKCS7 envelope to PEM certificate", "ca_id", ca.ID, "err", err)
return nil, ctxerr.Wrap(ctx, err, "converting PKCS7 envelope to PEM certificate")
}
return new(pemCert), nil
}
// Wrap the certificate in a PEM block for easier consumption by the client. TODO: If we ever
// support CAs other than Hydrant/EST in this API, this may need to be modified to be aware of
// their formats.
return new("-----BEGIN PKCS7-----\n" + string(certificate.Certificate) + "\n-----END PKCS7-----\n"), nil
}
// pkcs7EnvelopeToPEM converts a base64-encoded PKCS7 envelope (as returned by an EST
// /simpleenroll response, per RFC 7030) into a single PEM-encoded CERTIFICATE block.
// It returns an error unless the envelope contains exactly one certificate.
func pkcs7EnvelopeToPEM(envelope []byte) (string, error) {
// EST returns base64-encoded PKCS7 with potential whitespace; strip it before decoding.
stripped := strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == ' ' || r == '\t' {
return -1
}
return r
}, string(envelope))
derBytes, err := base64.StdEncoding.DecodeString(stripped)
if err != nil {
return "", fmt.Errorf("decoding base64 PKCS7 envelope: %w", err)
}
p7, err := pkcs7.Parse(derBytes)
if err != nil {
return "", fmt.Errorf("parsing PKCS7 envelope: %w", err)
}
// Per RFC 7030 §4.2.3, the EST /simpleenroll SimplePKIResponse carries the single
// issued certificate. Reject anything else so callers don't have to guess which cert
// is the leaf.
if len(p7.Certificates) != 1 {
return "", fmt.Errorf("expected exactly 1 certificate in EST PKCS7 envelope, got %d", len(p7.Certificates))
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: p7.Certificates[0].Raw})
if pemBytes == nil {
return "", errors.New("encoding certificate to PEM")
}
return string(pemBytes), nil
}
func (svc *Service) introspectIDPToken(ctx context.Context, idpClientID, idpToken, idpOauthURL string) (*oauthIntrospectionResponse, error) {
httpClient := fleethttp.NewClient(fleethttp.WithTimeout(20 * time.Second))
introspectionRequest := url.Values{
"client_id": []string{idpClientID},
"token": []string{idpToken},
}
introspectionBody := introspectionRequest.Encode()
req, err := http.NewRequestWithContext(ctx, "POST", idpOauthURL, strings.NewReader(introspectionBody))
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "Failed to create introspection request")
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "Failed to introspect IDP token")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, ctxerr.Errorf(ctx, "IDP token introspection failed with status code %d", resp.StatusCode)
}
oauthIntrospectionResponse := &oauthIntrospectionResponse{}
if err := json.NewDecoder(resp.Body).Decode(oauthIntrospectionResponse); err != nil {
return nil, ctxerr.Wrapf(ctx, err, "Failed to decode IDP token introspection response")
}
return oauthIntrospectionResponse, nil
}
func (svc *Service) parseCSR(ctx context.Context, csr string) (*x509.CertificateRequest, error) {
// unescape newlines
block, _ := pem.Decode([]byte(strings.ReplaceAll(csr, "\\n", "\n")))
if block == nil {
return nil, ctxerr.New(ctx, "invalid CSR format")
}
req, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to parse CSR")
}
return req, nil
}
// Extract email and UPN fields from the provided CSR. Assumes there is exactly 1 email and that there is a UPN SAN extension, will
// error otherwise
func (svc *Service) extractCSRUserInfo(ctx context.Context, req *x509.CertificateRequest) (string, string, error) {
if len(req.EmailAddresses) < 1 {
return "", "", ctxerr.New(ctx, "CSR does not contain an email address")
}
if len(req.EmailAddresses) > 1 {
return "", "", ctxerr.Errorf(ctx, "CSR contains %d email addresses, only 1 is supported", len(req.EmailAddresses))
}
csrEmail := req.EmailAddresses[0]
upn, err := extractCSRUPN(req)
if err != nil {
return "", "", ctxerr.Wrap(ctx, err, "failed to extract UPN from CSR")
}
if upn == nil {
return "", "", ctxerr.New(ctx, "CSR does not contain a UPN")
}
return csrEmail, *upn, nil
}
// The go standard library does not provide a way to extract the UPN from a CSR, so we must do it
// manually by first finding the SAN extension then looking in othernames for the UPN and parsing it.
func extractCSRUPN(csr *x509.CertificateRequest) (*string, error) {
sanOID := asn1.ObjectIdentifier{2, 5, 29, 17}
upnOID := asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 311, 20, 2, 3}
for _, ext := range csr.Extensions {
if ext.Id.Equal(sanOID) {
nameValues := []asn1.RawValue{}
if _, err := asn1.Unmarshal(ext.Value, &nameValues); err != nil {
return nil, fmt.Errorf("failed to unmarshal SAN extension: %w", err)
}
for _, names := range nameValues {
// We are looking for the othernames(tag 0) in the SAN extension
if names.Tag == 0 {
var oid asn1.ObjectIdentifier
var rawValue asn1.RawValue
var err error
remainingBytes := names.Bytes
// This will be a sequence of OID-value pairs that we must parse
for len(remainingBytes) > 0 {
remainingBytes, err = asn1.Unmarshal(names.Bytes, &oid)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal othername OID: %w", err)
}
// I am not sure what this would indicate. Perhaps a malformed CSR?
if len(remainingBytes) == 0 {
return nil, fmt.Errorf("unexpected end of input bytes after unmarshalling othername OID %s but before unmarshaling value", oid.String())
}
remainingBytes, err = asn1.Unmarshal(remainingBytes, &rawValue)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal othername value: %w", err)
}
if oid.Equal(upnOID) {
// Unmarshal the raw value into a string
var upn asn1.RawValue
if _, err := asn1.Unmarshal(rawValue.Bytes, &upn); err != nil {
return nil, fmt.Errorf("failed to unmarshal UPN value: %w", err)
}
upnString := string(upn.Bytes)
return &upnString, nil
}
}
}
}
}
}
return nil, nil // No UPN found
}