fleet/ee/server/service/request_certificate.go
Jordan Montgomery 994672ca20
Hydrant CA Feature Branch (#31807)
There are still some TODOs particularly within Gitops test code which
will be worked on in a followup PR

# 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)
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## 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)

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

For unreleased bug fixes in a release candidate, one of:

- [x] Confirmed that the fix is not expected to adversely impact load
test results
- [x] Alerted the release DRI if additional load testing is needed

## Database migrations

- [x] Checked table schema to confirm autoupdate
- [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`).

## New Fleet configuration settings

- [ ] Setting(s) is/are explicitly excluded from GitOps

If you didn't check the box above, follow this checklist for
GitOps-enabled settings:

- [ ] Verified that the setting is exported via `fleetctl
generate-gitops`
- [x] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [x] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [x] Verified that any relevant UI is disabled when GitOps mode is
enabled

---------

Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com>
Co-authored-by: Magnus Jensen <magnus@fleetdm.com>
Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com>
2025-09-04 12:39:41 -04:00

222 lines
9.2 KiB
Go

package service
import (
"context"
"crypto/x509"
"encoding/asn1"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/log/level"
)
// 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) {
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) {
return nil, &fleet.BadRequestError{Message: "This API currently only supports Hydrant Certificate Authorities."}
}
if ca.ClientSecret == nil {
return nil, &fleet.BadRequestError{Message: "Certificate authority does not have a client secret configured."}
}
certificateRequest, err := svc.parseCSR(ctx, p.CSR)
if err != nil {
level.Error(svc.logger).Log("msg", "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 {
level.Error(svc.logger).Log("msg", "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 {
level.Error(svc.logger).Log("msg", "Failed to introspect IDP token during certificate request", "idp_url", *p.IDPOauthURL, "err", err)
return nil, InvalidIDPTokenError{}
}
if !introspectionResponse.Active {
level.Error(svc.logger).Log("msg", "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 {
level.Error(svc.logger).Log("msg", "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) {
level.Error(svc.logger).Log("msg", "Failing Certificate Request due to mismatch between CSR email and UPN", "csr_email", csrEmail, "csr_upn", csrUsername)
return nil, InvalidCSRError{}
}
if csrEmail != *introspectionResponse.Username {
level.Error(svc.logger).Log("msg", "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", "")
certificate, err := svc.hydrantService.GetCertificate(ctx, fleet.HydrantCA{
Name: *ca.Name,
URL: *ca.URL,
ClientID: *ca.ClientID,
ClientSecret: *ca.ClientSecret,
}, csrForRequest)
if err != nil {
level.Error(svc.logger).Log("msg", "Hydrant certificate request failed", "ca_id", ca.ID, "error", 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("Hydrant certificate request failed: %s", err.Error())}
}
level.Info(svc.logger).Log("msg", "Successfully retrieved a certificate from Hydrant", "ca_id", ca.ID, "idp_username", idpUsername)
// Wrap the certificate in a PEM block for easier consumption by the client
return ptr.String("-----BEGIN CERTIFICATE-----\n" + string(certificate.Certificate) + "\n-----END CERTIFICATE-----\n"), 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
}