mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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 -->
This commit is contained in:
parent
a51420f201
commit
0180cc8086
27 changed files with 1188 additions and 62 deletions
1
Makefile
1
Makefile
|
|
@ -295,6 +295,7 @@ debug-go-tests:
|
|||
DEFAULT_PKGS_TO_TEST := ./cmd/... ./ee/... ./orbit/pkg/... ./orbit/cmd/orbit ./pkg/... ./server/... ./tools/...
|
||||
# fast tests are quick and do not require out-of-process dependencies (such as MySQL, etc.)
|
||||
FAST_PKGS_TO_TEST := \
|
||||
./ee/server/service/hostidentity/types \
|
||||
./ee/tools/mdm \
|
||||
./orbit/pkg/cryptoinfo \
|
||||
./orbit/pkg/dataflatten \
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/ee/server/scim"
|
||||
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/digicert"
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity"
|
||||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||||
"github.com/fleetdm/fleet/v4/pkg/scripts"
|
||||
"github.com/fleetdm/fleet/v4/server"
|
||||
|
|
@ -1193,6 +1194,18 @@ the way that the Fleet server works.
|
|||
if err = scim.RegisterSCIM(rootMux, ds, svc, logger); err != nil {
|
||||
initFatal(err, "setup SCIM")
|
||||
}
|
||||
// Host identify SCEP feature only works if a private key has been set up
|
||||
if len(config.Server.PrivateKey) > 0 {
|
||||
hostIdentitySCEPDepot, err := mds.NewHostIdentitySCEPDepot(kitlog.With(logger, "component", "host-id-scep-depot"))
|
||||
if err != nil {
|
||||
initFatal(err, "setup host identity SCEP depot")
|
||||
}
|
||||
if err = hostidentity.RegisterSCEP(rootMux, hostIdentitySCEPDepot, ds, logger); err != nil {
|
||||
initFatal(err, "setup host identity SCEP")
|
||||
}
|
||||
} else {
|
||||
level.Warn(logger).Log("msg", "Host identity SCEP is not available because no server private key has been set up.")
|
||||
}
|
||||
}
|
||||
|
||||
if config.Prometheus.BasicAuth.Username != "" && config.Prometheus.BasicAuth.Password != "" {
|
||||
|
|
|
|||
322
ee/server/integrationtest/hostidentity/hostidscep_test.go
Normal file
322
ee/server/integrationtest/hostidentity/hostidscep_test.go
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
package hostidentity
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
scepclient "github.com/fleetdm/fleet/v4/server/mdm/scep/client"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/scep/x509util"
|
||||
"github.com/smallstep/scep"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testEnrollmentSecret = "test_secret"
|
||||
|
||||
func TestHostIdentitySCEP(t *testing.T) {
|
||||
s := SetUpSuite(t, "integrationtest.HostIdentitySCEP")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(t *testing.T, s *Suite)
|
||||
}{
|
||||
{"GetCert", testGetCert},
|
||||
{"GetCertFailures", testGetCertFailures},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
defer mysql.TruncateTables(t, s.BaseSuite.DS, []string{
|
||||
"host_identity_scep_serials", "host_identity_scep_certificates",
|
||||
}...)
|
||||
c.fn(t, s)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testGetCert(t *testing.T, s *Suite) {
|
||||
t.Run("ECC P256", func(t *testing.T) {
|
||||
testGetCertWithCurve(t, s, elliptic.P256())
|
||||
})
|
||||
|
||||
t.Run("ECC P384", func(t *testing.T) {
|
||||
testGetCertWithCurve(t, s, elliptic.P384())
|
||||
})
|
||||
}
|
||||
|
||||
func testGetCertWithCurve(t *testing.T, s *Suite, curve elliptic.Curve) {
|
||||
ctx := t.Context()
|
||||
// Create an enrollment secret
|
||||
err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{
|
||||
{
|
||||
Secret: testEnrollmentSecret,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create ECC private key with specified curve
|
||||
eccPrivateKey, err := ecdsa.GenerateKey(curve, rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create SCEP client
|
||||
scepURL := fmt.Sprintf("%s/api/fleet/orbit/host_identity/scep", s.Server.URL)
|
||||
scepClient, err := scepclient.New(scepURL, s.Logger, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get CA certificate
|
||||
resp, _, err := scepClient.GetCACert(ctx, "")
|
||||
require.NoError(t, err)
|
||||
caCerts, err := x509.ParseCertificates(resp)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, caCerts)
|
||||
|
||||
// Create CSR using ECC key
|
||||
csrTemplate := x509util.CertificateRequest{
|
||||
CertificateRequest: x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: "test-host-identity",
|
||||
},
|
||||
SignatureAlgorithm: x509.ECDSAWithSHA256,
|
||||
},
|
||||
ChallengePassword: testEnrollmentSecret,
|
||||
}
|
||||
|
||||
csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, eccPrivateKey)
|
||||
require.NoError(t, err)
|
||||
csr, err := x509.ParseCertificateRequest(csrDerBytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
tempRSAKey, deviceCert := createTempRSAKeyAndCert(t, "test-host-identity")
|
||||
|
||||
// Create SCEP PKI message
|
||||
pkiMsgReq := &scep.PKIMessage{
|
||||
MessageType: scep.PKCSReq,
|
||||
Recipients: caCerts,
|
||||
SignerKey: tempRSAKey, // Use RSA key for SCEP protocol
|
||||
SignerCert: deviceCert,
|
||||
}
|
||||
|
||||
msg, err := scep.NewCSRRequest(csr, pkiMsgReq, scep.WithLogger(s.Logger))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send PKI operation request
|
||||
respBytes, err := scepClient.PKIOperation(ctx, msg.Raw)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse response
|
||||
pkiMsgResp, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(s.Logger), scep.WithCACerts(msg.Recipients))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify successful response
|
||||
require.Equal(t, scep.SUCCESS, pkiMsgResp.PKIStatus, "SCEP request should succeed")
|
||||
|
||||
// Decrypt PKI envelope using RSA key
|
||||
err = pkiMsgResp.DecryptPKIEnvelope(deviceCert, tempRSAKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify we got a certificate
|
||||
require.NotNil(t, pkiMsgResp.CertRepMessage)
|
||||
require.NotNil(t, pkiMsgResp.CertRepMessage.Certificate)
|
||||
|
||||
// Verify the certificate was signed by the CA
|
||||
cert := pkiMsgResp.CertRepMessage.Certificate
|
||||
require.NotNil(t, cert)
|
||||
|
||||
// Verify certificate properties
|
||||
assert.Equal(t, "test-host-identity", cert.Subject.CommonName)
|
||||
assert.Equal(t, x509.ECDSA, cert.PublicKeyAlgorithm)
|
||||
certPubKey, ok := cert.PublicKey.(*ecdsa.PublicKey)
|
||||
require.True(t, ok, "Certificate should contain ECC public key")
|
||||
assert.True(t, eccPrivateKey.PublicKey.Equal(certPubKey), "Certificate public key should match our ECC private key")
|
||||
assert.Equal(t, curve, certPubKey.Curve, "Certificate should use the expected elliptic curve")
|
||||
|
||||
// Retrieve the certificate from datastore and verify it matches SCEP response
|
||||
storedCert, err := s.DS.GetHostIdentityCertBySerialNumber(ctx, cert.SerialNumber.Uint64())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, storedCert)
|
||||
|
||||
// Verify stored certificate properties match the SCEP response
|
||||
assert.Equal(t, cert.SerialNumber.Uint64(), storedCert.SerialNumber)
|
||||
assert.Equal(t, cert.Subject.CommonName, storedCert.CommonName)
|
||||
assert.Equal(t, cert.NotAfter, storedCert.NotValidAfter)
|
||||
|
||||
// Verify the stored public key matches the certificate public key
|
||||
storedPubKey, err := storedCert.UnmarshalPublicKey()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, storedPubKey)
|
||||
assert.True(t, certPubKey.Equal(storedPubKey), "Stored public key should match certificate public key")
|
||||
assert.Equal(t, curve, storedPubKey.Curve, "Stored public key should use the expected elliptic curve")
|
||||
}
|
||||
|
||||
func createTempRSAKeyAndCert(t *testing.T, commonName string) (*rsa.PrivateKey, *x509.Certificate) {
|
||||
// Create temporary RSA key for SCEP envelope (required by SCEP protocol)
|
||||
tempRSAKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create self-signed certificate for SCEP protocol using RSA key
|
||||
deviceCertTemplate := x509.Certificate{
|
||||
Subject: pkix.Name{
|
||||
CommonName: commonName,
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
deviceCertDerBytes, err := x509.CreateCertificate(
|
||||
rand.Reader,
|
||||
&deviceCertTemplate,
|
||||
&deviceCertTemplate,
|
||||
&tempRSAKey.PublicKey,
|
||||
tempRSAKey,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
deviceCert, err := x509.ParseCertificate(deviceCertDerBytes)
|
||||
require.NoError(t, err)
|
||||
return tempRSAKey, deviceCert
|
||||
}
|
||||
|
||||
func testGetCertFailures(t *testing.T, s *Suite) {
|
||||
cases := []struct {
|
||||
name string
|
||||
config SCEPFailureConfig
|
||||
}{
|
||||
{
|
||||
name: "empty challenge password",
|
||||
config: SCEPFailureConfig{
|
||||
ChallengePassword: "",
|
||||
CommonName: "test-host-identity",
|
||||
UseECC: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong challenge password",
|
||||
config: SCEPFailureConfig{
|
||||
ChallengePassword: "wrong-secret",
|
||||
CommonName: "test-host-identity",
|
||||
UseECC: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CN longer than 255 characters",
|
||||
config: SCEPFailureConfig{
|
||||
ChallengePassword: testEnrollmentSecret,
|
||||
CommonName: strings.Repeat("a", 256),
|
||||
UseECC: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-ECC algorithm used",
|
||||
config: SCEPFailureConfig{
|
||||
ChallengePassword: testEnrollmentSecret,
|
||||
CommonName: "test-host-identity",
|
||||
UseECC: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
testSCEPFailure(t, s, c.config)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type SCEPFailureConfig struct {
|
||||
ChallengePassword string
|
||||
CommonName string
|
||||
UseECC bool
|
||||
}
|
||||
|
||||
func testSCEPFailure(t *testing.T, s *Suite, config SCEPFailureConfig) {
|
||||
ctx := t.Context()
|
||||
|
||||
// Create an enrollment secret
|
||||
err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{
|
||||
{
|
||||
Secret: testEnrollmentSecret,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create SCEP client
|
||||
scepURL := fmt.Sprintf("%s/api/fleet/orbit/host_identity/scep", s.Server.URL)
|
||||
scepClient, err := scepclient.New(scepURL, s.Logger, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get CA certificate
|
||||
resp, _, err := scepClient.GetCACert(ctx, "")
|
||||
require.NoError(t, err)
|
||||
caCerts, err := x509.ParseCertificates(resp)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, caCerts)
|
||||
|
||||
var privateKey interface{}
|
||||
var sigAlg x509.SignatureAlgorithm
|
||||
|
||||
if config.UseECC {
|
||||
// Create ECC private key
|
||||
eccKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
privateKey = eccKey
|
||||
sigAlg = x509.ECDSAWithSHA256
|
||||
} else {
|
||||
// Create RSA private key to test non-ECC algorithm rejection (should fail)
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
privateKey = rsaKey
|
||||
sigAlg = x509.SHA256WithRSA
|
||||
}
|
||||
|
||||
// Create CSR
|
||||
csrTemplate := x509util.CertificateRequest{
|
||||
CertificateRequest: x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: config.CommonName,
|
||||
},
|
||||
SignatureAlgorithm: sigAlg,
|
||||
},
|
||||
ChallengePassword: config.ChallengePassword,
|
||||
}
|
||||
|
||||
csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, privateKey)
|
||||
require.NoError(t, err)
|
||||
csr, err := x509.ParseCertificateRequest(csrDerBytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
tempRSAKey, deviceCert := createTempRSAKeyAndCert(t, config.CommonName)
|
||||
|
||||
// Create SCEP PKI message
|
||||
pkiMsgReq := &scep.PKIMessage{
|
||||
MessageType: scep.PKCSReq,
|
||||
Recipients: caCerts,
|
||||
SignerKey: tempRSAKey,
|
||||
SignerCert: deviceCert,
|
||||
}
|
||||
|
||||
msg, err := scep.NewCSRRequest(csr, pkiMsgReq, scep.WithLogger(s.Logger))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send PKI operation request
|
||||
respBytes, err := scepClient.PKIOperation(ctx, msg.Raw)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse response
|
||||
pkiMsgResp, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(s.Logger), scep.WithCACerts(msg.Recipients))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify failure response
|
||||
assert.Equal(t, scep.FAILURE, pkiMsgResp.PKIStatus, "SCEP request should fail")
|
||||
}
|
||||
51
ee/server/integrationtest/hostidentity/suite.go
Normal file
51
ee/server/integrationtest/hostidentity/suite.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package hostidentity
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
"github.com/fleetdm/fleet/v4/server/service/integrationtest"
|
||||
"github.com/go-kit/kit/log"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type Suite struct {
|
||||
integrationtest.BaseSuite
|
||||
}
|
||||
|
||||
func SetUpSuite(t *testing.T, uniqueTestName string) *Suite {
|
||||
// Note: t.Parallel() is called when MySQL datastore options are processed
|
||||
license := &fleet.LicenseInfo{
|
||||
Tier: fleet.TierPremium,
|
||||
}
|
||||
ds, fleetCfg, fleetSvc, ctx := integrationtest.SetUpMySQLAndService(t, uniqueTestName, &service.TestServerOpts{
|
||||
License: license,
|
||||
})
|
||||
logger := log.NewLogfmtLogger(os.Stdout)
|
||||
hostIdentitySCEPDepot, err := ds.NewHostIdentitySCEPDepot(kitlog.With(logger, "component", "host-id-scep-depot"))
|
||||
require.NoError(t, err)
|
||||
users, server := service.RunServerForTestsWithServiceWithDS(t, ctx, ds, fleetSvc, &service.TestServerOpts{
|
||||
License: license,
|
||||
FleetConfig: &fleetCfg,
|
||||
Logger: logger,
|
||||
HostIdentitySCEPStorage: hostIdentitySCEPDepot,
|
||||
})
|
||||
|
||||
s := &Suite{
|
||||
BaseSuite: integrationtest.BaseSuite{
|
||||
Logger: logger,
|
||||
DS: ds,
|
||||
FleetCfg: fleetCfg,
|
||||
Users: users,
|
||||
Server: server,
|
||||
},
|
||||
}
|
||||
|
||||
integrationtest.SetUpServerURL(t, ds, server)
|
||||
|
||||
s.BaseSuite.Token = s.BaseSuite.GetTestAdminToken(t)
|
||||
return s
|
||||
}
|
||||
50
ee/server/service/hostidentity/config.go
Normal file
50
ee/server/service/hostidentity/config.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package hostidentity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/certificate"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
|
||||
)
|
||||
|
||||
func initAssets(ds fleet.Datastore) error {
|
||||
// Check if we have existing certs and keys
|
||||
expectedAssets := []fleet.MDMAssetName{
|
||||
fleet.MDMAssetHostIdentityCACert,
|
||||
fleet.MDMAssetHostIdentityCAKey,
|
||||
}
|
||||
savedAssets, err := ds.GetAllMDMConfigAssetsByName(context.Background(), expectedAssets, nil)
|
||||
if err != nil {
|
||||
// allow not found errors as it means we're generating the assets for the first time.
|
||||
if !fleet.IsNotFound(err) {
|
||||
return fmt.Errorf("loading existing host identity assets from the database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(savedAssets) != len(expectedAssets) {
|
||||
// Then we should create them
|
||||
scepCert, scepKey, err := depot.NewSCEPCACertKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating host identity SCEP cert and key: %w", err)
|
||||
}
|
||||
|
||||
// Store our config assets encrypted
|
||||
var assets []fleet.MDMConfigAsset
|
||||
for k, v := range map[fleet.MDMAssetName][]byte{
|
||||
fleet.MDMAssetHostIdentityCACert: certificate.EncodeCertPEM(scepCert),
|
||||
fleet.MDMAssetHostIdentityCAKey: certificate.EncodePrivateKeyPEM(scepKey),
|
||||
} {
|
||||
assets = append(assets, fleet.MDMConfigAsset{
|
||||
Name: k,
|
||||
Value: v,
|
||||
})
|
||||
}
|
||||
|
||||
if err := ds.InsertMDMConfigAssets(context.Background(), assets, nil); err != nil {
|
||||
return fmt.Errorf("inserting host identity SCEP assets: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
136
ee/server/service/hostidentity/depot/depot.go
Normal file
136
ee/server/service/hostidentity/depot/depot.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package depot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
|
||||
"github.com/fleetdm/fleet/v4/pkg/certificate"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/assets"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
|
||||
"github.com/go-kit/log"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const maxCommonNameLength = 255
|
||||
|
||||
// HostIdentitySCEPDepot is a MySQL-backed SCEP certificate depot.
|
||||
type HostIdentitySCEPDepot struct {
|
||||
db *sqlx.DB
|
||||
ds fleet.Datastore
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
var _ depot.Depot = (*HostIdentitySCEPDepot)(nil)
|
||||
|
||||
// NewHostIdentitySCEPDepot creates and returns a *HostIdentitySCEPDepot.
|
||||
func NewHostIdentitySCEPDepot(db *sqlx.DB, ds fleet.Datastore, logger log.Logger) (*HostIdentitySCEPDepot, error) {
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &HostIdentitySCEPDepot{
|
||||
db: db,
|
||||
ds: ds,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CA returns the CA's certificate and private key.
|
||||
func (d *HostIdentitySCEPDepot) CA(_ []byte) ([]*x509.Certificate, *rsa.PrivateKey, error) {
|
||||
cert, err := assets.KeyPair(context.Background(), d.ds, fleet.MDMAssetHostIdentityCACert, fleet.MDMAssetHostIdentityCAKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("getting assets: %w", err)
|
||||
}
|
||||
|
||||
pk, ok := cert.PrivateKey.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("private key not in RSA format")
|
||||
}
|
||||
|
||||
return []*x509.Certificate{cert.Leaf}, pk, nil
|
||||
}
|
||||
|
||||
// Serial allocates and returns a new (increasing) serial number.
|
||||
func (d *HostIdentitySCEPDepot) Serial() (*big.Int, error) {
|
||||
// Insert an empty row to generate a new auto-incremented serial number
|
||||
result, err := d.db.Exec(`INSERT INTO host_identity_scep_serials () VALUES ();`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lid, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return big.NewInt(lid), nil
|
||||
}
|
||||
|
||||
// HasCN returns whether the given certificate exists in the depot.
|
||||
func (d *HostIdentitySCEPDepot) HasCN(cn string, allowTime int, cert *x509.Certificate, revokeOldCertificate bool) (bool, error) {
|
||||
// Not used right now. May be used for renewal in the future.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Put stores a certificate under the given name.
|
||||
//
|
||||
// If the provided certificate has empty crt.Subject.CommonName,
|
||||
// then the hex sha256 of the crt.Raw is used as name.
|
||||
func (d *HostIdentitySCEPDepot) Put(name string, crt *x509.Certificate) error {
|
||||
if crt.Subject.CommonName == "" || len(crt.Subject.CommonName) > maxCommonNameLength {
|
||||
return errors.New("common name empty or too long")
|
||||
}
|
||||
if !crt.SerialNumber.IsInt64() {
|
||||
return errors.New("cannot represent serial number as int64")
|
||||
}
|
||||
|
||||
// Extract the ECC uncompressed point (04-prefixed X || Y); 0x04 means this is the raw representation
|
||||
// Lengths:
|
||||
// - P-256: 65 bytes
|
||||
// - P-384: 97 bytes
|
||||
key, ok := crt.PublicKey.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return errors.New("public key not in ECDSA format")
|
||||
}
|
||||
pubKeyRaw, err := types.CreateECDSAPublicKeyRaw(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating public key raw: %w", err)
|
||||
}
|
||||
certPEM := certificate.EncodeCertPEM(crt)
|
||||
|
||||
return common_mysql.WithRetryTxx(context.Background(), d.db, func(tx sqlx.ExtContext) error {
|
||||
// Revoke existing certs for this host id.
|
||||
// Note: Because the challenge is shared, it is possible for a bad actor to revoke a cert for an existing host
|
||||
// if they have the challenge and the host identifier (CN).
|
||||
result, err := tx.ExecContext(context.Background(), `
|
||||
UPDATE host_identity_scep_certificates
|
||||
SET revoked = 1
|
||||
WHERE name = ?`, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected > 0 {
|
||||
d.logger.Log("msg", "revoked existing host identity certificate", "name", name)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(context.Background(), `
|
||||
INSERT INTO host_identity_scep_certificates
|
||||
(serial, name, not_valid_before, not_valid_after, certificate_pem, public_key_raw)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?)`,
|
||||
crt.SerialNumber.Int64(),
|
||||
name,
|
||||
crt.NotBefore,
|
||||
crt.NotAfter,
|
||||
certPEM,
|
||||
pubKeyRaw,
|
||||
)
|
||||
return err
|
||||
}, d.logger)
|
||||
}
|
||||
186
ee/server/service/hostidentity/scep.go
Normal file
186
ee/server/service/hostidentity/scep.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package hostidentity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/assets"
|
||||
scepdepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
|
||||
scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server"
|
||||
"github.com/go-kit/kit/log"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/smallstep/scep"
|
||||
)
|
||||
|
||||
const (
|
||||
scepPath = "/api/fleet/orbit/host_identity/scep"
|
||||
scepValidityDays = 365
|
||||
)
|
||||
|
||||
// RegisterSCEP registers the HTTP handler for SCEP service needed for fleetd enrollment.
|
||||
func RegisterSCEP(
|
||||
mux *http.ServeMux,
|
||||
scepStorage scepdepot.Depot,
|
||||
ds fleet.Datastore,
|
||||
logger kitlog.Logger,
|
||||
) error {
|
||||
err := initAssets(ds)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initializing host identity assets: %w", err)
|
||||
}
|
||||
var signer scepserver.CSRSignerContext = scepserver.SignCSRAdapter(scepdepot.NewSigner(
|
||||
scepStorage,
|
||||
scepdepot.WithValidityDays(scepValidityDays),
|
||||
scepdepot.WithAllowRenewalDays(scepValidityDays/2),
|
||||
))
|
||||
|
||||
signer = challengeMiddleware(ds, signer)
|
||||
scepService := NewSCEPService(
|
||||
ds,
|
||||
signer,
|
||||
kitlog.With(logger, "component", "host-id-scep"),
|
||||
)
|
||||
|
||||
scepLogger := kitlog.With(logger, "component", "http-host-id-scep")
|
||||
e := scepserver.MakeServerEndpoints(scepService)
|
||||
e.GetEndpoint = scepserver.EndpointLoggingMiddleware(scepLogger)(e.GetEndpoint)
|
||||
e.PostEndpoint = scepserver.EndpointLoggingMiddleware(scepLogger)(e.PostEndpoint)
|
||||
|
||||
// Note: Monitoring (APM/OpenTel) is missing for this SCEP server.
|
||||
// In addition, the scepserver error handler does not send errors to APM/Sentry/Redis.
|
||||
// It should be enhanced to do so if/when we start monitoring error traces.
|
||||
// This note also applies to the other SCEP servers we use.
|
||||
// That is why we're not using ctxerr wrappers here.
|
||||
scepHandler := scepserver.MakeHTTPHandler(e, scepService, scepLogger)
|
||||
mux.Handle(scepPath, scepHandler)
|
||||
return nil
|
||||
}
|
||||
|
||||
// challengeMiddleware checks that ChallengePassword matches an enrollment secret
|
||||
func challengeMiddleware(ds fleet.Datastore, next scepserver.CSRSignerContext) scepserver.CSRSignerContextFunc {
|
||||
return func(ctx context.Context, m *scep.CSRReqMessage) (*x509.Certificate, error) {
|
||||
if m.ChallengePassword == "" {
|
||||
return nil, errors.New("missing challenge")
|
||||
}
|
||||
_, err := ds.VerifyEnrollSecret(ctx, m.ChallengePassword)
|
||||
switch {
|
||||
case fleet.IsNotFound(err):
|
||||
return nil, errors.New("invalid challenge")
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("verifying enrollment secret: %w", err)
|
||||
}
|
||||
return next.SignCSRContext(ctx, m)
|
||||
}
|
||||
}
|
||||
|
||||
var _ scepserver.Service = (*service)(nil)
|
||||
|
||||
type service struct {
|
||||
// The (chainable) CSR signing function. Intended to handle all
|
||||
// SCEP request functionality such as CSR & challenge checking, CA
|
||||
// issuance, RA proxying, etc.
|
||||
signer scepserver.CSRSignerContext
|
||||
|
||||
logger log.Logger
|
||||
|
||||
ds fleet.MDMAssetRetriever
|
||||
}
|
||||
|
||||
func (svc *service) GetCACaps(_ context.Context) ([]byte, error) {
|
||||
// Supported SCEP CA Capabilities:
|
||||
//
|
||||
// Cryptographic and Algorithm Support:
|
||||
// [x] POSTPKIOperation // Supports HTTP POST for PKIOperation (preferred over GET)
|
||||
// [ ] SHA-1 // Supports SHA-1 for signing
|
||||
// [x] SHA-256 // Supports SHA-256 for signing
|
||||
// [ ] SHA-512 // Supports SHA-512 for signing
|
||||
// [x] AES // Supports AES encryption for PKCS#7 enveloped data
|
||||
// [ ] DES3 // Supports Triple DES encryption - older, weaker encryption
|
||||
//
|
||||
// Operational Capabilities:
|
||||
// [ ] GetNextCACert // Supports fetching next CA certificate (rollover)
|
||||
// [ ] Renewal // Supports certificate renewal (same key, new cert)
|
||||
// [ ] Update // Supports certificate update (new key)
|
||||
//
|
||||
// These capabilities are implied by the protocol and don't need to be explicitly declared:
|
||||
// [x] SCEPStandard // Conforms to a known SCEP standard version
|
||||
// [x] PKCS7 // Responses are in PKCS#7 format
|
||||
// [x] X509 // Supports X.509 certificates
|
||||
//
|
||||
defaultCaps := []byte("SHA-256\nAES\nPOSTPKIOperation")
|
||||
return defaultCaps, nil
|
||||
}
|
||||
|
||||
func (svc *service) GetCACert(ctx context.Context, _ string) ([]byte, int, error) {
|
||||
cert, err := caKeyPair(ctx, svc.ds)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("retrieving host identity SCEP CA certificate (GetCACert): %w", err)
|
||||
}
|
||||
return cert.Leaf.Raw, 1, nil
|
||||
}
|
||||
|
||||
func caKeyPair(ctx context.Context, ds fleet.MDMAssetRetriever) (*tls.Certificate, error) {
|
||||
return assets.KeyPair(ctx, ds, fleet.MDMAssetHostIdentityCACert, fleet.MDMAssetHostIdentityCAKey)
|
||||
}
|
||||
|
||||
func (svc *service) PKIOperation(ctx context.Context, data []byte) ([]byte, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, &fleet.BadRequestError{Message: "missing data for PKIOperation"}
|
||||
}
|
||||
msg, err := scep.ParsePKIMessage(data, scep.WithLogger(svc.logger))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cert, err := caKeyPair(ctx, svc.ds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("retrieving host identity SCEP CA certificate: %w", err)
|
||||
}
|
||||
|
||||
pk, ok := cert.PrivateKey.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("private key not in RSA format")
|
||||
}
|
||||
|
||||
if err := msg.DecryptPKIEnvelope(cert.Leaf, pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
crt, err := svc.signer.SignCSRContext(ctx, msg.CSRReqMessage)
|
||||
if err == nil && crt == nil {
|
||||
err = errors.New("signer returned nil certificate without error")
|
||||
}
|
||||
if err != nil {
|
||||
svc.logger.Log("msg", "failed to sign CSR", "err", err)
|
||||
certRep, err := msg.Fail(cert.Leaf, pk, scep.BadRequest)
|
||||
if certRep == nil {
|
||||
return nil, err
|
||||
}
|
||||
return certRep.Raw, err
|
||||
}
|
||||
|
||||
certRep, err := msg.Success(cert.Leaf, pk, crt)
|
||||
if certRep == nil {
|
||||
return nil, err
|
||||
}
|
||||
return certRep.Raw, err
|
||||
}
|
||||
|
||||
func (svc *service) GetNextCACert(_ context.Context) ([]byte, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
// NewSCEPService creates a new scep service
|
||||
func NewSCEPService(ds fleet.Datastore, signer scepserver.CSRSignerContext, logger log.Logger) scepserver.Service {
|
||||
return &service{
|
||||
ds: ds,
|
||||
signer: signer,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HostIdentityCertificate struct {
|
||||
SerialNumber uint64 `db:"serial"`
|
||||
CommonName string `db:"name"`
|
||||
HostID *uint `db:"host_id"`
|
||||
NotValidAfter time.Time `db:"not_valid_after"`
|
||||
PublicKeyRaw []byte `db:"public_key_raw"`
|
||||
}
|
||||
|
||||
func (h *HostIdentityCertificate) UnmarshalPublicKey() (*ecdsa.PublicKey, error) {
|
||||
if len(h.PublicKeyRaw) == 0 || h.PublicKeyRaw[0] != 4 { // 0x04 means this is the raw representation
|
||||
return nil, errors.New("unsupported EC point format")
|
||||
}
|
||||
|
||||
curve, err := guessCurve(h.PublicKeyRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byteLen := (len(h.PublicKeyRaw) - 1) / 2
|
||||
x := new(big.Int).SetBytes(h.PublicKeyRaw[1 : 1+byteLen])
|
||||
y := new(big.Int).SetBytes(h.PublicKeyRaw[1+byteLen:])
|
||||
|
||||
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
|
||||
}
|
||||
|
||||
func guessCurve(raw []byte) (elliptic.Curve, error) {
|
||||
switch len(raw) {
|
||||
case 65: // 0x04 + 32 + 32
|
||||
return elliptic.P256(), nil
|
||||
case 97: // 0x04 + 48 + 48
|
||||
return elliptic.P384(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown curve: unsupported key length %d", len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func CreateECDSAPublicKeyRaw(key *ecdsa.PublicKey) ([]byte, error) {
|
||||
var keySize int
|
||||
switch key.Curve {
|
||||
case elliptic.P256():
|
||||
keySize = 32
|
||||
case elliptic.P384():
|
||||
keySize = 48
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported curve: %s", key.Curve.Params().Name)
|
||||
}
|
||||
|
||||
// Pad X and Y coordinates to the expected size
|
||||
xBytes := make([]byte, keySize)
|
||||
yBytes := make([]byte, keySize)
|
||||
key.X.FillBytes(xBytes)
|
||||
key.Y.FillBytes(yBytes)
|
||||
|
||||
pubKeyRaw := append([]byte{0x04}, append(xBytes, yBytes...)...)
|
||||
return pubKeyRaw, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHostIdentityCertificate_PublicKey(t *testing.T) {
|
||||
t.Run("P256", func(t *testing.T) {
|
||||
testUnmarshalPublicKeyWithCurve(t, elliptic.P256())
|
||||
})
|
||||
|
||||
t.Run("P384", func(t *testing.T) {
|
||||
testUnmarshalPublicKeyWithCurve(t, elliptic.P384())
|
||||
})
|
||||
|
||||
t.Run("unsupported curve", func(t *testing.T) {
|
||||
// Generate a key with P521 curve (unsupported)
|
||||
key, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to create raw public key - should fail
|
||||
_, err = CreateECDSAPublicKeyRaw(&key.PublicKey)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported curve")
|
||||
})
|
||||
|
||||
t.Run("invalid format - missing 0x04 prefix", func(t *testing.T) {
|
||||
// Generate a P256 key but remove the 0x04 prefix
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create raw bytes without 0x04 prefix, padded to 32 bytes each
|
||||
xBytes := make([]byte, 32)
|
||||
yBytes := make([]byte, 32)
|
||||
key.X.FillBytes(xBytes)
|
||||
key.Y.FillBytes(yBytes)
|
||||
pubKeyRaw := xBytes
|
||||
pubKeyRaw = append(pubKeyRaw, yBytes...)
|
||||
|
||||
cert := &HostIdentityCertificate{
|
||||
PublicKeyRaw: pubKeyRaw,
|
||||
}
|
||||
|
||||
_, err = cert.UnmarshalPublicKey()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported EC point format")
|
||||
})
|
||||
|
||||
t.Run("invalid format - wrong prefix", func(t *testing.T) {
|
||||
// Generate a P256 key but use wrong prefix
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create raw bytes with wrong prefix (0x03 instead of 0x04)
|
||||
pubKeyRaw, err := CreateECDSAPublicKeyRaw(&key.PublicKey)
|
||||
require.NoError(t, err)
|
||||
pubKeyRaw[0] = 0x03
|
||||
|
||||
cert := &HostIdentityCertificate{
|
||||
PublicKeyRaw: pubKeyRaw,
|
||||
}
|
||||
|
||||
_, err = cert.UnmarshalPublicKey()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported EC point format")
|
||||
})
|
||||
|
||||
t.Run("empty public key", func(t *testing.T) {
|
||||
cert := &HostIdentityCertificate{
|
||||
PublicKeyRaw: []byte{},
|
||||
}
|
||||
|
||||
_, err := cert.UnmarshalPublicKey()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported EC point format")
|
||||
})
|
||||
|
||||
t.Run("unsupported key length", func(t *testing.T) {
|
||||
// Create a key with unsupported length (not 65 or 97 bytes)
|
||||
pubKeyRaw := make([]byte, 33) // 33 bytes total (0x04 + 16 + 16)
|
||||
pubKeyRaw[0] = 0x04
|
||||
|
||||
cert := &HostIdentityCertificate{
|
||||
PublicKeyRaw: pubKeyRaw,
|
||||
}
|
||||
|
||||
_, err := cert.UnmarshalPublicKey()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unknown curve")
|
||||
})
|
||||
}
|
||||
|
||||
func testUnmarshalPublicKeyWithCurve(t *testing.T, curve elliptic.Curve) {
|
||||
// Generate a key with the specified curve
|
||||
originalKey, err := ecdsa.GenerateKey(curve, rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
pubKeyRaw, err := CreateECDSAPublicKeyRaw(&originalKey.PublicKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create HostIdentityCertificate with the raw public key
|
||||
cert := &HostIdentityCertificate{
|
||||
PublicKeyRaw: pubKeyRaw,
|
||||
}
|
||||
|
||||
// Unmarshal the public key
|
||||
unmarshaledKey, err := cert.UnmarshalPublicKey()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, unmarshaledKey)
|
||||
|
||||
// Verify the unmarshaled key matches the original
|
||||
assert.Equal(t, curve, unmarshaledKey.Curve)
|
||||
assert.True(t, originalKey.X.Cmp(unmarshaledKey.X) == 0, "X coordinates should match")
|
||||
assert.True(t, originalKey.Y.Cmp(unmarshaledKey.Y) == 0, "Y coordinates should match")
|
||||
assert.True(t, originalKey.PublicKey.Equal(unmarshaledKey), "Public keys should be equal")
|
||||
}
|
||||
|
|
@ -3,8 +3,10 @@ package certificate
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
|
@ -237,3 +239,21 @@ func parseFullClientCertificate(crt, key []byte) (tls.Certificate, error) {
|
|||
cert.Leaf = parsedLeaf
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// EncodeCertPEM returns PEM-endcoded certificate data.
|
||||
func EncodeCertPEM(cert *x509.Certificate) []byte {
|
||||
block := pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: cert.Raw,
|
||||
}
|
||||
return pem.EncodeToMemory(&block)
|
||||
}
|
||||
|
||||
// EncodePrivateKeyPEM returns PEM-encoded private key data
|
||||
func EncodePrivateKeyPEM(key *rsa.PrivateKey) []byte {
|
||||
block := pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
}
|
||||
return pem.EncodeToMemory(&block)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -401,7 +401,6 @@ func (n notFoundErr) Error() string {
|
|||
}
|
||||
|
||||
func TestCalendarEvents1KHosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
ds := new(mock.Store)
|
||||
ctx := context.Background()
|
||||
var logger kitlog.Logger
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ type DatastoreTestOptions struct {
|
|||
|
||||
// RealReplica indicates that the replica should be a real DB replica, with a dedicated connection.
|
||||
RealReplica bool
|
||||
|
||||
UniqueTestName string
|
||||
}
|
||||
|
||||
func LoadSchema(t testing.TB, testName string, opts *DatastoreTestOptions, schemaPath string) {
|
||||
|
|
@ -182,16 +184,22 @@ func ProcessOptions(t testing.TB, opts *DatastoreTestOptions) (string, *Datastor
|
|||
}
|
||||
}
|
||||
|
||||
const numberOfStackFramesFromTest = 3
|
||||
pc, _, _, ok := runtime.Caller(numberOfStackFramesFromTest)
|
||||
details := runtime.FuncForPC(pc)
|
||||
if !ok || details == nil {
|
||||
t.FailNow()
|
||||
var cleanTestName string
|
||||
if opts.UniqueTestName != "" {
|
||||
cleanTestName = opts.UniqueTestName
|
||||
} else {
|
||||
const numberOfStackFramesFromTest = 3
|
||||
pc, _, _, ok := runtime.Caller(numberOfStackFramesFromTest)
|
||||
details := runtime.FuncForPC(pc)
|
||||
if !ok || details == nil {
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
cleanTestName = strings.ReplaceAll(
|
||||
strings.TrimPrefix(details.Name(), "github.com/fleetdm/fleet/v4/"), "/", "_",
|
||||
)
|
||||
}
|
||||
|
||||
cleanTestName := strings.ReplaceAll(
|
||||
strings.TrimPrefix(details.Name(), "github.com/fleetdm/fleet/v4/"), "/", "_",
|
||||
)
|
||||
cleanTestName = strings.ReplaceAll(cleanTestName, ".", "_")
|
||||
if len(cleanTestName) > 60 {
|
||||
// the later parts are more unique than the start, with the package names,
|
||||
|
|
|
|||
24
server/datastore/mysql/host_identity_scep.go
Normal file
24
server/datastore/mysql/host_identity_scep.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// Most of the code for the host identity feature is located at ./ee/server/service/hostidentity
|
||||
|
||||
func (ds *Datastore) GetHostIdentityCertBySerialNumber(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error) {
|
||||
var hostIdentityCert types.HostIdentityCertificate
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &hostIdentityCert, `
|
||||
SELECT serial, host_id, name, not_valid_after, public_key_raw
|
||||
FROM host_identity_scep_certificates
|
||||
WHERE serial = ?
|
||||
AND not_valid_after > NOW()
|
||||
AND revoked = 0`, serialNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &hostIdentityCert, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20250707095725, Down_20250707095725)
|
||||
}
|
||||
|
||||
func Up_20250707095725(tx *sql.Tx) error {
|
||||
// Create host_identity_scep_serials table first (referenced by foreign key)
|
||||
// In SCEP (Simple Certificate Enrollment Protocol) implementations, it's common practice to reserve serial number 1 for the CA (Certificate Authority) certificate itself or for other system-level certificates.
|
||||
_, err := tx.Exec(`
|
||||
CREATE TABLE host_identity_scep_serials (
|
||||
serial bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
created_at DATETIME(6) NULL DEFAULT NOW(6),
|
||||
PRIMARY KEY (serial)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create host_identity_scep_serials table: %w", err)
|
||||
}
|
||||
|
||||
// Create host_identity_scep_certificates table
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE host_identity_scep_certificates (
|
||||
serial bigint unsigned NOT NULL,
|
||||
host_id int unsigned NULL,
|
||||
name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
not_valid_before datetime NOT NULL,
|
||||
not_valid_after datetime NOT NULL,
|
||||
certificate_pem text COLLATE utf8mb4_unicode_ci NOT NULL, -- 65K max, stored for debug/auditing but not used
|
||||
public_key_raw VARBINARY(100) NOT NULL, -- for quick retrieval/verification
|
||||
revoked tinyint(1) NOT NULL DEFAULT '0',
|
||||
created_at DATETIME(6) NULL DEFAULT NOW(6),
|
||||
updated_at DATETIME(6) NULL DEFAULT NOW(6) ON UPDATE NOW(6),
|
||||
PRIMARY KEY (serial),
|
||||
KEY idx_host_id_scep_name (name), -- for quick revocation
|
||||
KEY idx_host_id_scep_host_id (host_id),
|
||||
CONSTRAINT host_identity_scep_certificates_ibfk_1 FOREIGN KEY (serial) REFERENCES host_identity_scep_serials (serial),
|
||||
CONSTRAINT host_identity_scep_certificates_chk_1 CHECK ((substr(certificate_pem,1,27) = _utf8mb4'-----BEGIN CERTIFICATE-----'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create host_identity_scep_certificates table: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20250707095725(_ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/XSAM/otelsql"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
hostidscepdepot "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/depot"
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
|
|
@ -183,12 +184,18 @@ func (ds *Datastore) deleteCachedStmt(query string) {
|
|||
}
|
||||
}
|
||||
|
||||
// NewMDMAppleSCEPDepot returns a scep_depot.Depot that uses the Datastore
|
||||
// NewSCEPDepot returns a scep_depot.Depot that uses the Datastore
|
||||
// underlying MySQL writer *sql.DB.
|
||||
func (ds *Datastore) NewSCEPDepot() (scep_depot.Depot, error) {
|
||||
return newSCEPDepot(ds.primary.DB, ds)
|
||||
}
|
||||
|
||||
// NewHostIdentitySCEPDepot returns a scep_depot.Depot for host identity certs that uses the Datastore
|
||||
// underlying MySQL writer *sql.DB.
|
||||
func (ds *Datastore) NewHostIdentitySCEPDepot(logger log.Logger) (scep_depot.Depot, error) {
|
||||
return hostidscepdepot.NewHostIdentitySCEPDepot(ds.primary, ds, logger)
|
||||
}
|
||||
|
||||
type entity struct {
|
||||
name string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import (
|
|||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/certificate"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/assets"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
|
||||
)
|
||||
|
|
@ -90,7 +90,7 @@ func (d *SCEPDepot) Put(name string, crt *x509.Certificate) error {
|
|||
if !crt.SerialNumber.IsInt64() {
|
||||
return errors.New("cannot represent serial number as int64")
|
||||
}
|
||||
certPEM := apple_mdm.EncodeCertPEM(crt)
|
||||
certPEM := certificate.EncodeCertPEM(crt)
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO scep_certificates
|
||||
(serial, name, not_valid_before, not_valid_after, certificate_pem)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -9,7 +9,7 @@ import (
|
|||
"math/big"
|
||||
"strings"
|
||||
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/pkg/certificate"
|
||||
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
|
||||
)
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ func (ds *Datastore) WSTEPStoreCertificate(ctx context.Context, name string, crt
|
|||
if !crt.SerialNumber.IsInt64() {
|
||||
return errors.New("cannot represent serial number as int64")
|
||||
}
|
||||
certPEM := apple_mdm.EncodeCertPEM(crt)
|
||||
certPEM := certificate.EncodeCertPEM(crt)
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, `
|
||||
INSERT INTO wstep_certificates
|
||||
(serial, name, not_valid_before, not_valid_after, certificate_pem)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/health"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
|
|
@ -2203,6 +2204,12 @@ type Datastore interface {
|
|||
// SetHostConditionalAccessStatus sets the "managed" and "compliant" statuses last set on Entra.
|
||||
// It does nothing if the host doesn't have a status entry created with CreateHostConditionalAccessStatus yet.
|
||||
SetHostConditionalAccessStatus(ctx context.Context, hostID uint, managed, compliant bool) error
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////////
|
||||
// Host identity certificates
|
||||
|
||||
// GetHostIdentityCertBySerialNumber gets the unrevoked valid cert corresponding to the provided serial number.
|
||||
GetHostIdentityCertBySerialNumber(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error)
|
||||
}
|
||||
|
||||
type AndroidDatastore interface {
|
||||
|
|
|
|||
|
|
@ -767,6 +767,10 @@ const (
|
|||
MDMAssetAndroidPubSubToken MDMAssetName = "android_pubsub_token" // nolint:gosec // Ignore G101: Potential hardcoded credentials
|
||||
// MDMAssetAndroidFleetServerSecret is the bearer token for Android requests sent to the fleetdm.com Android management proxy.
|
||||
MDMAssetAndroidFleetServerSecret MDMAssetName = "android_fleet_server_secret" // nolint:gosec // Ignore G101: Potential hardcoded credentials
|
||||
// MDMAssetHostIdentityCACert is the name of the root CA certificate used for host identity
|
||||
MDMAssetHostIdentityCACert MDMAssetName = "host_identity_ca_cert"
|
||||
// MDMAssetHostIdentityCAKey is the name of the root CA private key used for host identity
|
||||
MDMAssetHostIdentityCAKey MDMAssetName = "host_identity_ca_key"
|
||||
)
|
||||
|
||||
type MDMConfigAsset struct {
|
||||
|
|
|
|||
|
|
@ -202,27 +202,7 @@ func GetSignedAPNSCSRNoEmail(client *http.Client, csr *x509.CertificateRequest)
|
|||
// 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 {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
caCert := depot.NewCACert(
|
||||
depot.WithYears(10),
|
||||
depot.WithCommonName("Fleet"),
|
||||
)
|
||||
|
||||
crtBytes, err := caCert.SelfSign(rand.Reader, key.Public(), key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(crtBytes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return cert, key, nil
|
||||
return depot.NewSCEPCACertKey()
|
||||
}
|
||||
|
||||
// NEWDEPKeyPairPEM generates a new public key certificate and private key for downloading the Apple DEP token.
|
||||
|
|
|
|||
|
|
@ -26,15 +26,6 @@ func newPrivateKey() (*rsa.PrivateKey, error) {
|
|||
return rsa.GenerateKey(rand.Reader, rsaKeySize)
|
||||
}
|
||||
|
||||
// EncodeCertPEM returns PEM-endcoded certificate data.
|
||||
func EncodeCertPEM(cert *x509.Certificate) []byte {
|
||||
block := pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: cert.Raw,
|
||||
}
|
||||
return pem.EncodeToMemory(&block)
|
||||
}
|
||||
|
||||
func EncodeCertRequestPEM(cert *x509.CertificateRequest) []byte {
|
||||
pemBlock := &pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
|
|
@ -45,15 +36,6 @@ func EncodeCertRequestPEM(cert *x509.CertificateRequest) []byte {
|
|||
return pem.EncodeToMemory(pemBlock)
|
||||
}
|
||||
|
||||
// EncodePrivateKeyPEM returns PEM-encoded private key data
|
||||
func EncodePrivateKeyPEM(key *rsa.PrivateKey) []byte {
|
||||
block := pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
}
|
||||
return pem.EncodeToMemory(&block)
|
||||
}
|
||||
|
||||
// GenerateRandomPin generates a `lenght`-digit PIN number that takes into
|
||||
// account the current time as described in rfc4226 (for one time passwords)
|
||||
//
|
||||
|
|
|
|||
41
server/mdm/scep/depot/fleet.go
Normal file
41
server/mdm/scep/depot/fleet.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package depot
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
caCert := NewCACert(
|
||||
WithYears(10),
|
||||
WithCommonName("Fleet"),
|
||||
)
|
||||
|
||||
crtBytes, err := caCert.SelfSign(rand.Reader, key.Public(), key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(crtBytes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return cert, key, nil
|
||||
}
|
||||
|
||||
// Note Apple rejects CSRs if the key size is not 2048.
|
||||
const rsaKeySize = 2048
|
||||
|
||||
// newPrivateKey creates an RSA private key
|
||||
func newPrivateKey() (*rsa.PrivateKey, error) {
|
||||
return rsa.GenerateKey(rand.Reader, rsaKeySize)
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
|
|
@ -1412,6 +1413,8 @@ type CreateHostConditionalAccessStatusFunc func(ctx context.Context, hostID uint
|
|||
|
||||
type SetHostConditionalAccessStatusFunc func(ctx context.Context, hostID uint, managed bool, compliant bool) error
|
||||
|
||||
type GetHostIdentityCertBySerialNumberFunc func(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error)
|
||||
|
||||
type DataStore struct {
|
||||
HealthCheckFunc HealthCheckFunc
|
||||
HealthCheckFuncInvoked bool
|
||||
|
|
@ -3498,6 +3501,9 @@ type DataStore struct {
|
|||
SetHostConditionalAccessStatusFunc SetHostConditionalAccessStatusFunc
|
||||
SetHostConditionalAccessStatusFuncInvoked bool
|
||||
|
||||
GetHostIdentityCertBySerialNumberFunc GetHostIdentityCertBySerialNumberFunc
|
||||
GetHostIdentityCertBySerialNumberFuncInvoked bool
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
|
|
@ -8365,3 +8371,10 @@ func (s *DataStore) SetHostConditionalAccessStatus(ctx context.Context, hostID u
|
|||
s.mu.Unlock()
|
||||
return s.SetHostConditionalAccessStatusFunc(ctx, hostID, managed, compliant)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetHostIdentityCertBySerialNumber(ctx context.Context, serialNumber uint64) (*types.HostIdentityCertificate, error) {
|
||||
s.mu.Lock()
|
||||
s.GetHostIdentityCertBySerialNumberFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetHostIdentityCertBySerialNumberFunc(ctx, serialNumber)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql/testing_utils"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
|
|
@ -53,7 +54,9 @@ func SetUpMySQLAndService(t *testing.T, uniqueTestName string, opts ...*service.
|
|||
config.FleetConfig,
|
||||
fleet.Service, context.Context,
|
||||
) {
|
||||
ds := mysql.CreateMySQLDS(t)
|
||||
ds := mysql.CreateMySQLDSWithOptions(t, &testing_utils.DatastoreTestOptions{
|
||||
UniqueTestName: uniqueTestName,
|
||||
})
|
||||
test.AddAllHostsLabel(t, ds)
|
||||
|
||||
// Set up the required fields on AppConfig
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
|
||||
"github.com/VividCortex/mysqlerr"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/fleetdm/fleet/v4/pkg/certificate"
|
||||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||||
"github.com/fleetdm/fleet/v4/server"
|
||||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
|
|
@ -209,9 +210,9 @@ func (svc *Service) RequestMDMAppleCSR(ctx context.Context, email, org string) (
|
|||
}
|
||||
|
||||
// PEM-encode the cert and keys
|
||||
scepCACertPEM := apple_mdm.EncodeCertPEM(scepCACert)
|
||||
scepCAKeyPEM := apple_mdm.EncodePrivateKeyPEM(scepCAKey)
|
||||
apnsKeyPEM := apple_mdm.EncodePrivateKeyPEM(apnsKey)
|
||||
scepCACertPEM := certificate.EncodeCertPEM(scepCACert)
|
||||
scepCAKeyPEM := certificate.EncodePrivateKeyPEM(scepCAKey)
|
||||
apnsKeyPEM := certificate.EncodePrivateKeyPEM(apnsKey)
|
||||
|
||||
return &fleet.AppleCSR{
|
||||
APNsKey: apnsKeyPEM,
|
||||
|
|
@ -2525,9 +2526,9 @@ func (svc *Service) GetMDMAppleCSR(ctx context.Context) ([]byte, error) {
|
|||
// Store our config assets encrypted
|
||||
var assets []fleet.MDMConfigAsset
|
||||
for k, v := range map[fleet.MDMAssetName][]byte{
|
||||
fleet.MDMAssetCACert: apple_mdm.EncodeCertPEM(scepCert),
|
||||
fleet.MDMAssetCAKey: apple_mdm.EncodePrivateKeyPEM(scepKey),
|
||||
fleet.MDMAssetAPNSKey: apple_mdm.EncodePrivateKeyPEM(apnsRSAKey),
|
||||
fleet.MDMAssetCACert: certificate.EncodeCertPEM(scepCert),
|
||||
fleet.MDMAssetCAKey: certificate.EncodePrivateKeyPEM(scepKey),
|
||||
fleet.MDMAssetAPNSKey: certificate.EncodePrivateKeyPEM(apnsRSAKey),
|
||||
} {
|
||||
assets = append(assets, fleet.MDMConfigAsset{
|
||||
Name: k,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/ee/server/scim"
|
||||
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/digicert"
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity"
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
||||
|
|
@ -363,6 +364,7 @@ type TestServerOpts struct {
|
|||
DigiCertService fleet.DigiCertService
|
||||
EnableSCIM bool
|
||||
ConditionalAccessMicrosoftProxy ConditionalAccessMicrosoftProxy
|
||||
HostIdentitySCEPStorage scep_depot.Depot
|
||||
}
|
||||
|
||||
func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServerOpts) (map[string]fleet.User, *httptest.Server) {
|
||||
|
|
@ -470,6 +472,10 @@ func RunServerForTestsWithServiceWithDS(t *testing.T, ctx context.Context, ds fl
|
|||
rootMux.Handle("/api/latest/fleet/scim/details", apiHandler)
|
||||
}
|
||||
|
||||
if len(opts) > 0 && opts[0].HostIdentitySCEPStorage != nil {
|
||||
require.NoError(t, hostidentity.RegisterSCEP(rootMux, opts[0].HostIdentitySCEPStorage, ds, logger))
|
||||
}
|
||||
|
||||
server := httptest.NewUnstartedServer(rootMux)
|
||||
server.Config = cfg.Server.DefaultHTTPServer(ctx, rootMux)
|
||||
// WriteTimeout is set for security purposes.
|
||||
|
|
|
|||
Loading…
Reference in a new issue