mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
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
<!-- 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>
552 lines
22 KiB
Go
552 lines
22 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"log/slog"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/ee/pkg/hostidentity/types"
|
|
"github.com/fleetdm/fleet/v4/ee/server/service/est"
|
|
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig"
|
|
"github.com/fleetdm/fleet/v4/server/authz"
|
|
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mock"
|
|
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/smallstep/pkcs7"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
// Go makes it a bit of a pain to generate a CSR with both a SAN email and UPN so the below
|
|
// was generated with the following openSSL commands:
|
|
/*
|
|
UUID="85700036-11ef-11e1-bbda-389239cc2c41"
|
|
UPN="fleetie@example.com"
|
|
USERNAME="${UPN}"
|
|
# USERNAME="badactor@example.com" # uncomment to make "bad" CSR
|
|
|
|
# generate the password-protected private key
|
|
openssl genpkey -algorithm RSA -out test.key -pkeyopt rsa_keygen_bits:2048 -aes256 -pass pass:$UUID
|
|
|
|
# generate CSR signed with that private key
|
|
openssl req -new -sha256 -key test.key -out test.csr -subj /CN=Test -addext "subjectAltName=DNS:example.com, email:$USERNAME, URI:ID:FleetDM:GUID:$UUID, otherName:msUPN;UTF8:$UPN" -passin pass:$UUID
|
|
|
|
# modify CSR to be one-line with \n string literal characters to address API limitations
|
|
sed 's/$/\\n/' test.csr | tr -d '\n' > test-escaped.csr
|
|
*/
|
|
goodCSR = "-----BEGIN CERTIFICATE REQUEST-----\nMIIC8jCCAdoCAQAwDzENMAsGA1UEAwwEVGVzdDCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBALMrkHOVZWVGv9PqU20NgpWed9MdRtMc8406GGWQJ3Rj9/8J\ncy8LOx1d5/XWLKK5VbN2c1hD/a26qkgHtDMfzRXnv5oFybkhaI5tlc9yhQmJVFI2\nRIBsSkZvIlX+SNWV2RuiyVHyGbjhzi3wZen1s0aOeXMMHdD5FVEngX4Fz3TuTb/Z\n8romrsSmWb32fQyQxola9/xe0IAnXZocrxi4xPjNKQbEN/2+gQ/MRJx+c+xnV3MV\nIrXn+8Av8MMBsXhCDlmT2QrpRezNAwWwRni9yKOb0sZMtTDrsCOgAmWsj0Qxf/AS\nMPh7xbozXK4ubf5ombYxEdwGgYl/IKQUKvBKYMMCAwEAAaCBnTCBmgYJKoZIhvcN\nAQkOMYGMMIGJMIGGBgNVHREEfzB9ggtleGFtcGxlLmNvbYETZmxlZXRpZUBleGFt\ncGxlLmNvbYY0SUQ6RmxlZXRETTpHVUlEOjg1NzAwMDM2LTExZWYtMTFlMS1iYmRh\nLTM4OTIzOWNjMmM0MaAjBgorBgEEAYI3FAIDoBUME2ZsZWV0aWVAZXhhbXBsZS5j\nb20wDQYJKoZIhvcNAQELBQADggEBABSBUwyvH/B4kMi9haabDmXpgjb+I7GN2ibz\nN9xS0D/p1TEPNZ2owMdd71oEUPO+pL4PeOIKkn/TRm5ZjnVHtlwlz9PPtkyg7n0d\n6v1L0PPn17jMu9o5u984oP+PYt/VXjJfqzSv2QY2fuR7u108bnxVfWh03n0w1+is\npDQhM5jT+RmXbeOiMIwLojwsYV78y3IYu9ElskonL2v8HQUD9yP8TKlASEhYOD7N\npPLSre8uKL3+A1nyvhG53Ia5xID9mQR3cMO0g6wOoCMerJ4QYMX9jkfPolteT25m\n3NKghdVqvxjm/Oxp7ZFn7LsbdALjnXDYbnNYl8BQTc1rMInnuOw=\n-----END CERTIFICATE REQUEST-----\n"
|
|
badCSR = "-----BEGIN CERTIFICATE REQUEST-----\nMIIC9DCCAdwCAQAwDzENMAsGA1UEAwwEVGVzdDCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAKUUUwsYGpfCCFZYPFL2KLMtf9QdKTizvv3xGPPh6exUo5tB\nEIyhuifEbVIJwf5BhL3104rAY1uywdcUIHqHtWcmaEzS8G6vn1hE4iOMMh5qG6e2\nzobHTxeRgOSeUKHGXWy93BqS09Nkj5H8zlTJO6NjwD3SKiDYZGQDhljdsHTw9Txt\ndHHrEi+y4Qn4FoAf/ie7x2OmfemhLIqpLpU6BxMmqiEHkGObNNlgFGsHGGC3qs9G\nR+2roK3r+nQouMKbFL2CqDCd6F/dBfSSYgOTeOJeOLoM6mZuYqF7dTC1ZU9xhPIR\nzwi9sodQ6kYj++ZycUGT56s6/0yEc4E2AUHAeB0CAwEAAaCBnzCBnAYJKoZIhvcN\nAQkOMYGOMIGLMIGIBgNVHREEgYAwfoILZXhhbXBsZS5jb22BFGJhZGFjdG9yQGV4\nYW1wbGUuY29thjRJRDpGbGVldERNOkdVSUQ6ODU3MDAwMzYtMTFlZi0xMWUxLWJi\nZGEtMzg5MjM5Y2MyYzQxoCMGCisGAQQBgjcUAgOgFQwTZmxlZXRpZUBleGFtcGxl\nLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEASt8qgOCQTtYYYr7KDMcp90Kw+ZiJAL8k\nyRhJy4OsiO4mCdUVvzkyccfV+n6U/51ktPjYkWc1CVYXa+KNN/Z0prsAKYmonR9/\nJh3VVeZrwyglsw+X2ct/H9neOC433KfstRYAZ5WGCSaBJRN1+SUI23O6fjQN7DaL\ntzBPMXMcfNZoWj8rbM/E0WjTnlgUi6L3Ppys5xq1vupdQCiryE8J8A9kKHnMyEi4\nqkCoKOBajEIT9tyFKg5NDjMbIAHFLoUWpLeEtgrGnq5bqBE+q/gOUFb+uqJmQQQz\nVlzFj30tfmt3uBq79Wne1Hu0S634eaCbHOmbuOmLforQqzKpaHXqPQ==\n-----END CERTIFICATE REQUEST-----\n"
|
|
)
|
|
|
|
func TestRequestCertificate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Setup mock Oauth server
|
|
defaultOauthIntrospectResponse := map[string]interface{}{
|
|
"active": true,
|
|
"username": "fleetie@example.com",
|
|
}
|
|
oauthIntrospectResponse := defaultOauthIntrospectResponse
|
|
oauthIntrospectStatus := http.StatusOK
|
|
mockOauthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/oauth2/v1/introspect" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
if oauthIntrospectStatus != http.StatusOK {
|
|
w.WriteHeader(oauthIntrospectStatus)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
err := json.NewEncoder(w).Encode(oauthIntrospectResponse)
|
|
require.NoError(t, err)
|
|
}))
|
|
defer mockOauthServer.Close()
|
|
|
|
// Setup mock hydrant server
|
|
defaultHydrantSimpleEnrollResponse := "abc123"
|
|
hydrantSimpleEnrollResponse := defaultHydrantSimpleEnrollResponse
|
|
hydrantSimpleEnrollStatus := http.StatusOK
|
|
|
|
mockHydrantServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
if r.URL.Path != "/cacerts" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/pkcs7-mime")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, err := w.Write([]byte("Imagine if there was actually CA cert data here..."))
|
|
require.NoError(t, err)
|
|
return
|
|
}
|
|
|
|
if r.Method != http.MethodPost || r.URL.Path != "/simpleenroll" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if hydrantSimpleEnrollStatus != http.StatusOK {
|
|
w.WriteHeader(hydrantSimpleEnrollStatus)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
_, err := w.Write([]byte(hydrantSimpleEnrollResponse))
|
|
require.NoError(t, err)
|
|
}))
|
|
defer mockHydrantServer.Close()
|
|
|
|
hydrantCA := &fleet.CertificateAuthority{
|
|
ID: 1,
|
|
Name: ptr.String("TestHydrantCA"),
|
|
Type: string(fleet.CATypeHydrant),
|
|
URL: &mockHydrantServer.URL,
|
|
ClientID: ptr.String("test-client-id"),
|
|
ClientSecret: ptr.String("test-client-secret"),
|
|
}
|
|
digicertCA := &fleet.CertificateAuthority{
|
|
ID: 2,
|
|
Name: ptr.String("TestDigiCertCA"),
|
|
Type: string(fleet.CATypeDigiCert),
|
|
URL: ptr.String("https://api.digicert.com"),
|
|
APIToken: ptr.String("test-api-token"),
|
|
ProfileID: ptr.String("test-profile-id"),
|
|
}
|
|
customESTCA := &fleet.CertificateAuthority{
|
|
ID: 3,
|
|
Name: ptr.String("TestCustomESTCA"),
|
|
Type: string(fleet.CATypeCustomESTProxy),
|
|
URL: &mockHydrantServer.URL,
|
|
Username: ptr.String("test-username"),
|
|
Password: ptr.String("test-password"),
|
|
}
|
|
|
|
useDefaultAuthContext := true
|
|
|
|
baseSetupForTests := func() (*Service, context.Context) {
|
|
ds := new(mock.Store)
|
|
|
|
// Setup DS mocks
|
|
ds.GetCertificateAuthorityByIDFunc = func(ctx context.Context, id uint, includeSecrets bool) (*fleet.CertificateAuthority, error) {
|
|
require.True(t, includeSecrets, "RequestCertificate should always fetch secrets")
|
|
for _, ca := range []*fleet.CertificateAuthority{hydrantCA, digicertCA, customESTCA} {
|
|
if ca.ID == id {
|
|
return ca, nil
|
|
}
|
|
}
|
|
return nil, common_mysql.NotFound("certificate authority")
|
|
}
|
|
|
|
ds.GetCertificateAuthorityByIDFuncInvoked = false
|
|
authorizer, err := authz.NewAuthorizer()
|
|
require.NoError(t, err)
|
|
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
|
svc := &Service{
|
|
logger: logger,
|
|
ds: ds,
|
|
authz: authorizer,
|
|
estService: est.NewService(
|
|
est.WithTimeout(2*time.Second),
|
|
est.WithLogger(logger),
|
|
),
|
|
}
|
|
|
|
authCtx := &authz_ctx.AuthorizationContext{}
|
|
ctx := authz_ctx.NewContext(context.Background(), authCtx)
|
|
if useDefaultAuthContext {
|
|
authCtx.SetAuthnMethod(authz_ctx.AuthnUserToken)
|
|
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
|
|
}
|
|
|
|
oauthIntrospectResponse = defaultOauthIntrospectResponse
|
|
oauthIntrospectStatus = http.StatusOK
|
|
hydrantSimpleEnrollResponse = defaultHydrantSimpleEnrollResponse
|
|
hydrantSimpleEnrollStatus = http.StatusOK
|
|
|
|
return svc, ctx
|
|
}
|
|
|
|
invalidCSR := InvalidCSRError{}
|
|
invalidIDP := InvalidIDPTokenError{}
|
|
|
|
t.Run("Request a certificate - Happy path", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
IDPOauthURL: ptr.String(mockOauthServer.URL + "/oauth2/v1/introspect"),
|
|
IDPToken: ptr.String("test-idp-token"),
|
|
IDPClientID: ptr.String("test-client-id"), // Missing client ID
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cert)
|
|
require.Equal(t, "-----BEGIN PKCS7-----\n"+hydrantSimpleEnrollResponse+"\n-----END PKCS7-----\n", *cert)
|
|
})
|
|
|
|
t.Run("Request a certificate - Happy path, no IDP", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cert)
|
|
require.Equal(t, "-----BEGIN PKCS7-----\n"+hydrantSimpleEnrollResponse+"\n-----END PKCS7-----\n", *cert)
|
|
})
|
|
|
|
t.Run("Request a certificate - Happy path, no IDP, http sig auth", func(t *testing.T) {
|
|
useDefaultAuthContext = false
|
|
defer func() { useDefaultAuthContext = true }()
|
|
|
|
svc, ctx := baseSetupForTests()
|
|
|
|
authCtx, ok := authz_ctx.FromContext(ctx)
|
|
require.True(t, ok)
|
|
authCtx.SetAuthnMethod(authz_ctx.AuthnHTTPMessageSignature)
|
|
ctx = httpsig.NewContext(ctx, types.HostIdentityCertificate{HostID: ptr.Uint(1), NotValidAfter: time.Now().Add(24 * time.Hour)})
|
|
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cert)
|
|
require.Equal(t, "-----BEGIN PKCS7-----\n"+hydrantSimpleEnrollResponse+"\n-----END PKCS7-----\n", *cert)
|
|
})
|
|
|
|
t.Run("Request a certificate - Happy path, no IDP, UPN does not match IDP info(should pass)", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: badCSR,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cert)
|
|
require.Equal(t, "-----BEGIN PKCS7-----\n"+hydrantSimpleEnrollResponse+"\n-----END PKCS7-----\n", *cert)
|
|
})
|
|
|
|
t.Run("Request a certificate - CA returns error", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
hydrantSimpleEnrollResponse = "Oh no! Something bad happened"
|
|
hydrantSimpleEnrollStatus = http.StatusInternalServerError
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
IDPOauthURL: ptr.String(mockOauthServer.URL + "/oauth2/v1/introspect"),
|
|
IDPToken: ptr.String("test-idp-token"),
|
|
IDPClientID: ptr.String("test-client-id"), // Missing client ID
|
|
})
|
|
require.ErrorContains(t, err, "EST certificate request failed")
|
|
require.Nil(t, cert)
|
|
})
|
|
|
|
t.Run("Request a certificate - IDP introspection reports non-active token", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
oauthIntrospectResponse = map[string]interface{}{
|
|
"active": false,
|
|
}
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
IDPOauthURL: ptr.String(mockOauthServer.URL + "/oauth2/v1/introspect"),
|
|
IDPToken: ptr.String("test-idp-token"),
|
|
IDPClientID: ptr.String("test-client-id"), // Missing client ID
|
|
})
|
|
require.ErrorAs(t, err, &invalidIDP)
|
|
require.Nil(t, cert)
|
|
})
|
|
|
|
t.Run("Request a certificate - IDP introspection does not return a username", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
oauthIntrospectResponse = map[string]interface{}{
|
|
"active": true,
|
|
}
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
IDPOauthURL: ptr.String(mockOauthServer.URL + "/oauth2/v1/introspect"),
|
|
IDPToken: ptr.String("test-idp-token"),
|
|
IDPClientID: ptr.String("test-client-id"), // Missing client ID
|
|
})
|
|
require.ErrorAs(t, err, &invalidIDP)
|
|
require.Nil(t, cert)
|
|
})
|
|
|
|
t.Run("Request a certificate - IDP introspection returns an error", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
oauthIntrospectResponse = map[string]interface{}{
|
|
"error": "something bad happened",
|
|
}
|
|
oauthIntrospectStatus = http.StatusInternalServerError
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
IDPOauthURL: ptr.String(mockOauthServer.URL + "/oauth2/v1/introspect"),
|
|
IDPToken: ptr.String("test-idp-token"),
|
|
IDPClientID: ptr.String("test-client-id"), // Missing client ID
|
|
})
|
|
require.ErrorAs(t, err, &invalidIDP)
|
|
require.Nil(t, cert)
|
|
})
|
|
|
|
t.Run("Request a certificate - Custom EST CA", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: customESTCA.ID,
|
|
CSR: goodCSR,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cert)
|
|
require.Equal(t, "-----BEGIN PKCS7-----\n"+hydrantSimpleEnrollResponse+"\n-----END PKCS7-----\n", *cert)
|
|
})
|
|
|
|
t.Run("Request certificate - non-Hydrant and non-EST CA", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
_, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: digicertCA.ID,
|
|
CSR: goodCSR,
|
|
IDPOauthURL: ptr.String(mockOauthServer.URL + "/oauth2/v1/introspect"),
|
|
IDPToken: ptr.String("test-idp-token"),
|
|
IDPClientID: ptr.String("test-idp-client-id"),
|
|
})
|
|
require.ErrorContains(t, err, "This API currently only supports Hydrant and EST Certificate Authorities.")
|
|
})
|
|
|
|
t.Run("Request certificate - nonexistent CA", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
_, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: 999,
|
|
CSR: goodCSR,
|
|
IDPOauthURL: ptr.String(mockOauthServer.URL + "/oauth2/v1/introspect"),
|
|
IDPToken: ptr.String("test-idp-token"),
|
|
IDPClientID: ptr.String("test-idp-client-id"),
|
|
})
|
|
require.ErrorContains(t, err, "certificate authority was not found in the datastore")
|
|
})
|
|
|
|
t.Run("Request certificate - missing IDP client ID", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
_, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
IDPOauthURL: ptr.String(mockOauthServer.URL + "/oauth2/v1/introspect"),
|
|
IDPToken: ptr.String("test-idp-token"),
|
|
IDPClientID: nil, // Missing client ID
|
|
})
|
|
require.ErrorContains(t, err, "IDP Client ID, Token, and OAuth URL all must be provided, if any are provided when requesting a certificate.")
|
|
})
|
|
|
|
t.Run("Request certificate - missing IDP token", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
_, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
IDPOauthURL: ptr.String(mockOauthServer.URL + "/oauth2/v1/introspect"),
|
|
IDPToken: nil, // Missing IDP token
|
|
IDPClientID: ptr.String("test-client-id"),
|
|
})
|
|
require.ErrorContains(t, err, "IDP Client ID, Token, and OAuth URL all must be provided, if any are provided when requesting a certificate.")
|
|
})
|
|
|
|
t.Run("Request certificate - missing IDP oauth URL", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
_, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
IDPOauthURL: nil,
|
|
IDPToken: ptr.String("test-idp-token"),
|
|
IDPClientID: ptr.String("test-client-id"), // Missing client ID
|
|
})
|
|
require.ErrorContains(t, err, "IDP Client ID, Token, and OAuth URL all must be provided, if any are provided when requesting a certificate.")
|
|
})
|
|
|
|
t.Run("Request certificate - CSR email and UPN do not match", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
_, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: badCSR,
|
|
IDPOauthURL: ptr.String(mockOauthServer.URL + "/oauth2/v1/introspect"),
|
|
IDPToken: ptr.String("test-idp-token"),
|
|
IDPClientID: ptr.String("test-client-id"), // Missing client ID
|
|
})
|
|
require.ErrorAs(t, err, &invalidCSR)
|
|
})
|
|
|
|
t.Run("Request certificate - CSR is not a CSR, IDP provided", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
_, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: "I'm not a CSR at all",
|
|
IDPOauthURL: ptr.String(mockOauthServer.URL + "/oauth2/v1/introspect"),
|
|
IDPToken: ptr.String("test-idp-token"),
|
|
IDPClientID: ptr.String("test-client-id"), // Missing client ID
|
|
})
|
|
require.ErrorAs(t, err, &invalidCSR)
|
|
})
|
|
|
|
t.Run("Request a certificate - CSR is not a CSR, no IDP provided", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
|
|
_, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: "I am not a CSR",
|
|
})
|
|
require.ErrorAs(t, err, &invalidCSR)
|
|
})
|
|
|
|
t.Run("Request a certificate - return_pem_certificate true", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
|
|
issuedDER := generateTestCertDER(t)
|
|
envelope, err := pkcs7.DegenerateCertificate(issuedDER)
|
|
require.NoError(t, err)
|
|
hydrantSimpleEnrollResponse = base64.StdEncoding.EncodeToString(envelope)
|
|
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
ReturnPEMCertificate: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cert)
|
|
|
|
block, rest := pem.Decode([]byte(*cert))
|
|
require.NotNil(t, block)
|
|
require.Equal(t, "CERTIFICATE", block.Type)
|
|
require.Equal(t, issuedDER, block.Bytes)
|
|
require.Empty(t, rest)
|
|
|
|
parsed, err := x509.ParseCertificate(block.Bytes)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "fleetie@example.com", parsed.Subject.CommonName)
|
|
})
|
|
|
|
t.Run("Request a certificate - return_pem_certificate true with whitespace in EST response", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
|
|
issuedDER := generateTestCertDER(t)
|
|
envelope, err := pkcs7.DegenerateCertificate(issuedDER)
|
|
require.NoError(t, err)
|
|
// EST servers may insert line breaks in the base64 body; ensure we tolerate them.
|
|
b64 := base64.StdEncoding.EncodeToString(envelope)
|
|
var withNewlines strings.Builder
|
|
for i := 0; i < len(b64); i += 64 {
|
|
end := min(i+64, len(b64))
|
|
withNewlines.WriteString(b64[i:end])
|
|
withNewlines.WriteByte('\n')
|
|
}
|
|
hydrantSimpleEnrollResponse = withNewlines.String()
|
|
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
ReturnPEMCertificate: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cert)
|
|
|
|
block, _ := pem.Decode([]byte(*cert))
|
|
require.NotNil(t, block)
|
|
require.Equal(t, "CERTIFICATE", block.Type)
|
|
require.Equal(t, issuedDER, block.Bytes)
|
|
|
|
parsed, err := x509.ParseCertificate(block.Bytes)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "fleetie@example.com", parsed.Subject.CommonName)
|
|
})
|
|
|
|
t.Run("Request a certificate - return_pem_certificate true, malformed PKCS7 returns error", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
// hydrantSimpleEnrollResponse defaults to "abc123" which is not a valid PKCS7 envelope.
|
|
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
ReturnPEMCertificate: true,
|
|
})
|
|
require.Error(t, err)
|
|
require.Nil(t, cert)
|
|
})
|
|
|
|
t.Run("Request a certificate - return_pem_certificate true rejects envelope with multiple certs", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
|
|
// Build a PKCS7 SignedData containing two certificates to verify we reject anything
|
|
// that doesn't match RFC 7030's single-issued-certificate response shape.
|
|
signed, err := pkcs7.NewSignedData(nil)
|
|
require.NoError(t, err)
|
|
signed.AddCertificate(parseDERCert(t, generateTestCertDER(t)))
|
|
signed.AddCertificate(parseDERCert(t, generateTestCertDER(t)))
|
|
envelope, err := signed.Finish()
|
|
require.NoError(t, err)
|
|
hydrantSimpleEnrollResponse = base64.StdEncoding.EncodeToString(envelope)
|
|
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
ReturnPEMCertificate: true,
|
|
})
|
|
require.ErrorContains(t, err, "expected exactly 1 certificate")
|
|
require.Nil(t, cert)
|
|
})
|
|
|
|
t.Run("Request a certificate - return_pem_certificate false preserves PKCS7 wrapping", func(t *testing.T) {
|
|
svc, ctx := baseSetupForTests()
|
|
|
|
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
|
ID: hydrantCA.ID,
|
|
CSR: goodCSR,
|
|
ReturnPEMCertificate: false,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cert)
|
|
require.Equal(t, "-----BEGIN PKCS7-----\n"+hydrantSimpleEnrollResponse+"\n-----END PKCS7-----\n", *cert)
|
|
})
|
|
}
|
|
|
|
// parseDERCert parses DER-encoded certificate bytes for use as input to PKCS7 SignedData.
|
|
func parseDERCert(t *testing.T, der []byte) *x509.Certificate {
|
|
t.Helper()
|
|
cert, err := x509.ParseCertificate(der)
|
|
require.NoError(t, err)
|
|
return cert
|
|
}
|
|
|
|
// generateTestCertDER returns DER-encoded bytes of a freshly generated self-signed certificate
|
|
// for use in tests that need a realistic PKCS7 envelope payload.
|
|
func generateTestCertDER(t *testing.T) []byte {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: "fleetie@example.com"},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(time.Hour),
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
|
require.NoError(t, err)
|
|
return der
|
|
}
|