From 494832589280aaf12b368992b0ed8eaa825b077f Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Fri, 18 Jul 2025 11:31:52 -0300 Subject: [PATCH] fleetd generate TPM key and issue SCEP certificate (#30932) #30461 This PR contains the changes for the happy path. On a separate PR we will be adding tests and further fixes for edge cases. - [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. - [ ] Added/updated automated tests - [x] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [ ] Make sure fleetd is compatible with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)). - [ ] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [ ] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [ ] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). ## Summary by CodeRabbit * **New Features** * Added support for using a TPM-backed key and SCEP-issued certificate to sign HTTP requests, enhancing security through hardware-based key management. * Introduced new CLI and environment flags to enable TPM-backed client certificates for Linux packages and Orbit. * Added a local HTTPS proxy that automatically signs requests using the TPM-backed key. * **Bug Fixes** * Improved cleanup and restart behavior when authentication fails with a host identity certificate. * **Tests** * Added comprehensive tests for SCEP client functionality and TPM integration. * **Chores** * Updated scripts and documentation to support TPM-backed client certificate packaging and configuration. --- changes/30461-fleetd-generate-tpm-key | 1 + cmd/fleetctl/fleetctl/package.go | 15 + cmd/osquery-perf/agent.go | 1 + cmd/osquery-perf/hostidentity/hostidentity.go | 16 +- ee/orbit/pkg/hostidentity/host_identity.go | 158 +++++ ee/orbit/pkg/httpsigproxy/httpsigproxy.go | 237 +++++++ ee/orbit/pkg/scep/scep.go | 318 +++++++++ ee/orbit/pkg/scep/scep_test.go | 258 +++++++ ee/orbit/pkg/scep/testdata/ca.crt | 30 + ee/orbit/pkg/scep/testdata/ca.key | 54 ++ ee/orbit/pkg/scep/testdata/ca.pem | 30 + ee/orbit/pkg/securehw/example_linux_test.go | 92 +++ ee/orbit/pkg/securehw/securehw.go | 84 +++ ee/orbit/pkg/securehw/securehw_linux.go | 632 ++++++++++++++++++ ee/orbit/pkg/securehw/securehw_stub.go | 14 + .../hostidentity/hostidentity_test.go | 8 +- .../hostidentity/scep_rate_limit_test.go | 2 +- ee/server/service/hostidentity/depot/depot.go | 3 - .../service/hostidentity/httpsig/httpsig.go | 10 +- .../hostidentity/httpsig/middleware.go | 97 +-- ee/server/service/scep_proxy.go | 8 +- go.mod | 1 + go.sum | 2 + orbit/changes/fleetd-tpm-key | 1 + orbit/cmd/orbit/orbit.go | 117 +++- orbit/pkg/constant/constant.go | 2 + orbit/pkg/packaging/linux_shared.go | 1 + orbit/pkg/packaging/packaging.go | 2 + pkg/fleethttpsig/fleethttpsig.go | 40 ++ server/mdm/scep/client/client.go | 46 +- server/mdm/scep/cmd/scepclient/scepclient.go | 2 +- server/mdm/scep/server/endpoint.go | 69 +- server/service/orbit_client.go | 20 +- tools/tuf/test/create_repository.sh | 8 +- tools/tuf/test/gen_pkgs.sh | 5 + 35 files changed, 2292 insertions(+), 92 deletions(-) create mode 100644 changes/30461-fleetd-generate-tpm-key create mode 100644 ee/orbit/pkg/hostidentity/host_identity.go create mode 100644 ee/orbit/pkg/httpsigproxy/httpsigproxy.go create mode 100644 ee/orbit/pkg/scep/scep.go create mode 100644 ee/orbit/pkg/scep/scep_test.go create mode 100644 ee/orbit/pkg/scep/testdata/ca.crt create mode 100644 ee/orbit/pkg/scep/testdata/ca.key create mode 100644 ee/orbit/pkg/scep/testdata/ca.pem create mode 100644 ee/orbit/pkg/securehw/example_linux_test.go create mode 100644 ee/orbit/pkg/securehw/securehw.go create mode 100644 ee/orbit/pkg/securehw/securehw_linux.go create mode 100644 ee/orbit/pkg/securehw/securehw_stub.go create mode 100644 orbit/changes/fleetd-tpm-key create mode 100644 pkg/fleethttpsig/fleethttpsig.go diff --git a/changes/30461-fleetd-generate-tpm-key b/changes/30461-fleetd-generate-tpm-key new file mode 100644 index 0000000000..8ca88163f4 --- /dev/null +++ b/changes/30461-fleetd-generate-tpm-key @@ -0,0 +1 @@ +* Added flag `--fleet-managed-client-certificate` to generate fleetd packages for linux that use TPMs to sign HTTP requests. diff --git a/cmd/fleetctl/fleetctl/package.go b/cmd/fleetctl/fleetctl/package.go index bc866390b9..5e1b009f29 100644 --- a/cmd/fleetctl/fleetctl/package.go +++ b/cmd/fleetctl/fleetctl/package.go @@ -255,6 +255,12 @@ func packageCommand() *cli.Command { Value: "", Destination: &opt.CustomOutfile, }, + &cli.BoolFlag{ + Name: "fleet-managed-client-certificate", + Usage: "Configures fleetd to use TPM-backed key to sign HTTP requests. This functionality is licensed under the Fleet EE License. Usage requires a current Fleet EE subscription.", + EnvVars: []string{"FLEETCTL_FLEET_MANAGED_CLIENT_CERTIFICATE"}, + Destination: &opt.FleetManagedClientCertificate, + }, }, Action: func(c *cli.Context) error { if opt.FleetURL != "" || opt.EnrollSecret != "" { @@ -285,6 +291,15 @@ func packageCommand() *cli.Command { } } + if opt.FleetManagedClientCertificate { + if c.String("type") != "deb" && c.String("type") != "rpm" { + return errors.New("--fleet-managed-client-certificate is only supported for deb/rpm packages") + } + if opt.FleetTLSClientCertificate != "" { + return errors.New("--fleet-managed-client-certificate and --fleet-tls-client-certificate may not be provided together") + } + } + // Perform checks on the provided update client certificate and key. if (opt.UpdateTLSClientCertificate != "") != (opt.UpdateTLSClientKey != "") { return errors.New("must specify both update-tls-client-certificate and update-tls-client-key") diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go index 12e6fe970a..e05895be14 100644 --- a/cmd/osquery-perf/agent.go +++ b/cmd/osquery-perf/agent.go @@ -744,6 +744,7 @@ func (a *agent) runOrbitLoop() { }, nil, signerWrapper, + "", ) if err != nil { log.Println("creating orbit client: ", err) diff --git a/cmd/osquery-perf/hostidentity/hostidentity.go b/cmd/osquery-perf/hostidentity/hostidentity.go index a2492b1100..2a8eeb7579 100644 --- a/cmd/osquery-perf/hostidentity/hostidentity.go +++ b/cmd/osquery-perf/hostidentity/hostidentity.go @@ -16,6 +16,7 @@ import ( "net/http" "time" + "github.com/fleetdm/fleet/v4/pkg/fleethttpsig" scepclient "github.com/fleetdm/fleet/v4/server/mdm/scep/client" "github.com/fleetdm/fleet/v4/server/mdm/scep/x509util" kitlog "github.com/go-kit/log" @@ -104,7 +105,10 @@ func (c *Client) RequestCertificate() error { // Create SCEP client with no-op logger and 30-second timeout scepURL := fmt.Sprintf("%s/api/fleet/orbit/host_identity/scep", c.config.ServerAddress) timeout := 30 * time.Second - scepClient, err := scepclient.New(scepURL, kitlog.NewNopLogger(), &timeout) + scepClient, err := scepclient.New(scepURL, kitlog.NewNopLogger(), + scepclient.WithTimeout(&timeout), + scepclient.Insecure(), + ) if err != nil { log.Printf("Agent %d: Failed to create SCEP client: %v", c.config.AgentIndex, err) return err @@ -230,19 +234,11 @@ func (c *Client) RequestCertificate() error { return fmt.Errorf("unsupported curve: %v", eccPrivateKey.Curve) } - signer, err := httpsig.NewSigner(httpsig.SigningProfile{ - Algorithm: algo, - Fields: httpsig.Fields("@method", "@authority", "@path", "@query", "content-digest"), - Metadata: []httpsig.Metadata{httpsig.MetaKeyID, httpsig.MetaCreated, httpsig.MetaNonce}, - }, httpsig.SigningKey{ - Key: eccPrivateKey, - MetaKeyID: fmt.Sprintf("%X", cert.SerialNumber), - }) + signer, err := fleethttpsig.Signer(fmt.Sprintf("%X", cert.SerialNumber), eccPrivateKey, algo) if err != nil { log.Printf("Agent %d: Failed to create HTTP signer: %v", c.config.AgentIndex, err) return err } - c.httpSigner = signer log.Printf("Agent %d: Successfully obtained host identity certificate with serial %X", c.config.AgentIndex, cert.SerialNumber) diff --git a/ee/orbit/pkg/hostidentity/host_identity.go b/ee/orbit/pkg/hostidentity/host_identity.go new file mode 100644 index 0000000000..49adfd6d45 --- /dev/null +++ b/ee/orbit/pkg/hostidentity/host_identity.go @@ -0,0 +1,158 @@ +package hostidentity + +import ( + "context" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/fleetdm/fleet/v4/ee/orbit/pkg/scep" + "github.com/fleetdm/fleet/v4/ee/orbit/pkg/securehw" + "github.com/fleetdm/fleet/v4/orbit/pkg/constant" + "github.com/rs/zerolog" +) + +// Credentials holds a certificate and its corresponding private key handle stored in secure hardware. +type Credentials struct { + // Certificate holds the public certificate issued via SCEP. + Certificate *x509.Certificate + // SecureHWKey holds the private key protected by secure hardware. + SecureHWKey securehw.Key + + // CertificatePath is the file path to the public certificate issued via SCEP. + CertificatePath string + + secureHW securehw.TEE +} + +// Close releases key resources. +func (c *Credentials) Close() { + c.secureHW.Close() +} + +// Setup creates a private key using a TEE and generates a new client +// certificate using SCEP. +// If there's already a key and certificate in the metadata directory it will return them. +// The returned Credentials needs to be closed after its use. +func Setup( + ctx context.Context, + metadataDir string, + scepURL string, + scepChallenge string, + commonName string, + rootCA string, + insecure bool, + logger zerolog.Logger, +) (*Credentials, error) { + teeDevice, err := securehw.New(metadataDir, logger) + if err != nil { + return nil, fmt.Errorf("failed to initialize TEE device: %w", err) + } + secureHWKey, err := teeDevice.LoadKey() + switch { + case err == nil: + // OK + case errors.As(err, &securehw.ErrKeyNotFound{}): + // Key doesn't exist yet, let's create it. + secureHWKey, err = teeDevice.CreateKey() + if err != nil { + return nil, fmt.Errorf("failed to create TEE key: %w", err) + } + default: + return nil, fmt.Errorf("failed to load TEE key: %w", err) + } + + clientCert, err := loadSCEPClientCert(metadataDir) + switch { + case err == nil: + // OK, we have a certificate already, let's use it. + case errors.Is(err, os.ErrNotExist): + // We don't have a certificate, let's issue one using SCEP. + opts := []scep.Option{ + scep.WithRootCA(rootCA), + scep.WithSigningKey(secureHWKey), + scep.WithLogger(logger), + scep.WithURL(scepURL), + scep.WithChallenge(scepChallenge), + scep.WithCommonName(commonName), + } + if insecure { + opts = append(opts, scep.Insecure()) + } + scepClient, err := scep.NewClient(opts...) + if err != nil { + return nil, fmt.Errorf("failed to create SCEP client: %w", err) + } + clientCert, err = scepClient.FetchCert(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch certificate using SCEP: %w", err) + } + if err := saveSCEPClientCert(metadataDir, clientCert); err != nil { + return nil, fmt.Errorf("failed to save certificate: %w", err) + } + } + + // Sanity check in case the public key material on the secure HW + // does not match the certificate public key. + // This can happen if something or someone deletes the private and public blobs + // and they are re-generated at startup. + + secureHWPubKey, err := secureHWKey.Public() + if err != nil { + return nil, fmt.Errorf("error getting public key from secure HW key: %w", err) + } + keysEqual, err := scep.PublicKeysEqual(secureHWPubKey, clientCert.PublicKey) + if err != nil { + return nil, fmt.Errorf("error comparing public keys: %w", err) + } + if !keysEqual { + // Cleanup the certificate in the metadata directory so that on next start up it will re-issue + // a new certificate. + certPath := filepath.Join(metadataDir, constant.FleetHTTPSignatureCertificateFileName) + if err := os.Remove(certPath); err != nil { + return nil, fmt.Errorf("error cleaning up %s: %w", certPath, err) + } + return nil, fmt.Errorf("secure HW key does not match certificate public key, deleted %q to re-issue a new certificate in the next restart", certPath) + } + logger.Debug().Msg("secure HW key matches certificate public key") + + return &Credentials{ + Certificate: clientCert, + SecureHWKey: secureHWKey, + CertificatePath: filepath.Join(metadataDir, constant.FleetHTTPSignatureCertificateFileName), + + secureHW: teeDevice, + }, nil +} + +func loadSCEPClientCert(metadataDir string) (*x509.Certificate, error) { + certPath := filepath.Join(metadataDir, constant.FleetHTTPSignatureCertificateFileName) + certPEMBytes, err := os.ReadFile(certPath) + if err != nil { + return nil, fmt.Errorf("open %q: %w", certPath, err) + } + block, _ := pem.Decode(certPEMBytes) + if block == nil || block.Type != "CERTIFICATE" { + return nil, errors.New("failed to decode PEM block containing certificate") + } + return x509.ParseCertificate(block.Bytes) +} + +func saveSCEPClientCert(metadataDir string, cert *x509.Certificate) error { + certPath := filepath.Join(metadataDir, constant.FleetHTTPSignatureCertificateFileName) + certFile, err := os.OpenFile(certPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("create cert file: %w", err) + } + defer certFile.Close() + if err := pem.Encode(certFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }); err != nil { + return fmt.Errorf("encode cert: %w", err) + } + return nil +} diff --git a/ee/orbit/pkg/httpsigproxy/httpsigproxy.go b/ee/orbit/pkg/httpsigproxy/httpsigproxy.go new file mode 100644 index 0000000000..54af2ff4fd --- /dev/null +++ b/ee/orbit/pkg/httpsigproxy/httpsigproxy.go @@ -0,0 +1,237 @@ +package httpsig + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fleetdm/fleet/v4/orbit/pkg/constant" + "github.com/fleetdm/fleet/v4/pkg/certificate" + "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/pkg/secure" + "github.com/remitly-oss/httpsig-go" +) + +const ( + // We are using TLS 1.3 with ECC P-256 private key for performance. + // Using this private key should cause TLS to use ECDHE-ECDSA cipher, which is faster due to a smaller key and lower compute cost. + // To generate private key and cert: + // openssl req -new -x509 \ + // -newkey ec:<(openssl ecparam -name prime256v1) \ + // -keyout ec_key.pem \ + // -out ec_cert.pem \ + // -days 8250 \ + // -nodes \ + // -subj "/CN=httpsig-proxy" \ + // -addext "subjectAltName = IP:127.0.0.1, IP:::1" + + // serverCert is the certificate used by the proxy server to connect to osquery via 127.0.0.1. + serverCert = `-----BEGIN CERTIFICATE----- +MIIBqTCCAU6gAwIBAgIUCvG0XCIQmOo/16H+G4pE3tgIlg0wCgYIKoZIzj0EAwIw +GDEWMBQGA1UEAwwNaHR0cHNpZy1wcm94eTAeFw0yNTA2MjQwMzQzMTFaFw00ODAx +MjUwMzQzMTFaMBgxFjAUBgNVBAMMDWh0dHBzaWctcHJveHkwWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAARJk0Q6QQYCSJamw8DUxDO8o60uU2TLa4JMJ7AEZSMX3Lc4 +hwBR9WJ8bpAnvTqnF1shU01oGIOgOaH0xh84pcO+o3YwdDAdBgNVHQ4EFgQUZpLu +MKWmoOPGXmy3wkoCz/JBG5UwHwYDVR0jBBgwFoAUZpLuMKWmoOPGXmy3wkoCz/JB +G5UwDwYDVR0TAQH/BAUwAwEB/zAhBgNVHREEGjAYhwR/AAABhxAAAAAAAAAAAAAA +AAAAAAABMAoGCCqGSM49BAMCA0kAMEYCIQCypDp3B7t9Lqgxgnhl8ve2MAgiO2H4 +Oq5EZgjt2ng0NwIhAKJyrItRC91gDDK2MOtWa7n8j6KjY3Kghbf4YKI/cU2l +-----END CERTIFICATE----- +` + + // serverKey is the corresponding private key. This key is compromised by + // being in the source code, rendering any connection using this cert + // insecure. This is OK since this connection will only be done to 127.0.0.1. + serverKey = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3ETz2yDl69ThBQ/o +XDL5o0YINWELb+ZJ0d5laq1ECdahRANCAARJk0Q6QQYCSJamw8DUxDO8o60uU2TL +a4JMJ7AEZSMX3Lc4hwBR9WJ8bpAnvTqnF1shU01oGIOgOaH0xh84pcO+ +-----END PRIVATE KEY----- +` +) + +// Proxy is the TLS proxy implementation for adding HTTP signatures. This type should only be +// initialized via NewProxy. +type Proxy struct { + // ParsedURL is the localhost URL the proxy is listening too. + ParsedURL *url.URL + CertificatePath string + + listener net.Listener + server *http.Server +} + +// NewProxy creates a new proxy implementation targeting the provided hostname. +func NewProxy( + proxyDirectory string, + targetURL string, + rootCA string, + insecure bool, + signer *httpsig.Signer, +) (*Proxy, error) { + // Directory to store proxy related assets + if err := secure.MkdirAll(proxyDirectory, constant.DefaultDirMode); err != nil { + return nil, fmt.Errorf("there was a problem creating the proxy directory: %w", err) + } + // Write certificate that the local proxy will use. + certPath := filepath.Join(proxyDirectory, "proxy.crt") + if err := os.WriteFile(certPath, []byte(serverCert), os.FileMode(0o644)); err != nil { + return nil, fmt.Errorf("write server cert: %w", err) + } + + cert, err := tls.X509KeyPair([]byte(serverCert), []byte(serverKey)) + if err != nil { + return nil, fmt.Errorf("load keypair: %w", err) + } + cfg := &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS13, // TLS 1.3 has a faster handshake than 1.2 + } + + // Assign any available port + listener, err := tls.Listen("tcp", "127.0.0.1:0", cfg) + if err != nil { + return nil, fmt.Errorf("bind 127.0.0.1: %w", err) + } + + addr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + return nil, errors.New("listener is not *net.TCPAddr") + } + + handler, err := newProxyHandler(targetURL, rootCA, insecure, signer) + if err != nil { + return nil, fmt.Errorf("make proxy handler: %w", err) + } + + proxy := &Proxy{ + // Rewrite URL to the proxy URL. Note the proxy handles any URL + // prefix so we don't need to carry that over here. + // We use 127.0.0.1 and NOT localhost due to security. + // A misconfigured /etc/hosts could resolve localhost to something unexpected. + ParsedURL: &url.URL{ + Scheme: "https", + Host: fmt.Sprintf("127.0.0.1:%d", addr.Port), + }, + CertificatePath: certPath, + listener: listener, + server: &http.Server{ + Handler: handler, + ReadHeaderTimeout: 5 * time.Minute, + }, + } + + return proxy, nil +} + +// Serve will begin running the proxy. +func (p *Proxy) Serve() error { + if p.listener == nil || p.server == nil { + return errors.New("listener and handler must not be nil -- initialize Proxy via NewProxy") + } + err := p.server.Serve(p.listener) + return fmt.Errorf("servetls returned: %w", err) +} + +// Close the server and associated listener. The server may not be reused after +// calling Close(). +func (p *Proxy) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return p.server.Shutdown(ctx) +} + +func newProxyHandler(targetURL string, rootCA string, insecure bool, signer *httpsig.Signer) (*httputil.ReverseProxy, error) { + target, err := url.Parse(targetURL) + if err != nil { + return nil, fmt.Errorf("parse target url: %w", err) + } + + transport := fleethttp.NewTransport() + switch { + case insecure: + transport.TLSClientConfig.InsecureSkipVerify = true + case rootCA != "": + rootCAs, err := certificate.LoadPEM(rootCA) + if err != nil { + return nil, fmt.Errorf("loading server root CA: %w", err) + } + transport.TLSClientConfig.RootCAs = rootCAs + } + + reverseProxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.Host = target.Host + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL) + }, + Transport: &signingRoundTripper{ + signer: signer, + transport: transport, + }, + } + return reverseProxy, nil +} + +// Copied from Go source +// https://go.googlesource.com/go/+/go1.15.6/src/net/http/httputil/reverseproxy.go#114 +func joinURLPath(a, b *url.URL) (path, rawpath string) { + if a.RawPath == "" && b.RawPath == "" { + return singleJoiningSlash(a.Path, b.Path), "" + } + // Same as singleJoiningSlash, but uses EscapedPath to determine + // whether a slash should be added + apath := a.EscapedPath() + bpath := b.EscapedPath() + aslash := strings.HasSuffix(apath, "/") + bslash := strings.HasPrefix(bpath, "/") + switch { + case aslash && bslash: + return a.Path + b.Path[1:], apath + bpath[1:] + case !aslash && !bslash: + return a.Path + "/" + b.Path, apath + "/" + bpath + } + return a.Path + b.Path, apath + bpath +} + +// Copied from Go source +// https://go.googlesource.com/go/+/go1.15.6/src/net/http/httputil/reverseproxy.go#102 +func singleJoiningSlash(a, b string) string { + aslash := strings.HasSuffix(a, "/") + bslash := strings.HasPrefix(b, "/") + switch { + case aslash && bslash: + return a + b[1:] + case !aslash && !bslash: + return a + "/" + b + } + return a + b +} + +type signingRoundTripper struct { + signer *httpsig.Signer + transport http.RoundTripper +} + +func (s *signingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Sign the request before sending + if err := s.signer.Sign(req); err != nil { + return nil, fmt.Errorf("signing request: %#v", err) + } + + // Remove X-Forwarded-For because we are forwarding from 127.0.0.1, + // which is a non-standard use of this header and may be rejected by some load balancers. + req.Header.Del("X-Forwarded-For") + + return s.transport.RoundTrip(req) +} diff --git a/ee/orbit/pkg/scep/scep.go b/ee/orbit/pkg/scep/scep.go new file mode 100644 index 0000000000..2c15bb0724 --- /dev/null +++ b/ee/orbit/pkg/scep/scep.go @@ -0,0 +1,318 @@ +package scep + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "fmt" + "time" + + scepclient "github.com/fleetdm/fleet/v4/server/mdm/scep/client" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/rs/zerolog" + "github.com/smallstep/scep" + "github.com/smallstep/scep/x509util" +) + +// SigningKey are keys that can generate a crypto.Signer type. +type SigningKey interface { + // Signer returns a crypto.Signer that uses this key for signing operations. + // The returned Signer is safe for concurrent use. + Signer() (crypto.Signer, error) +} + +// Client fetches a certificate using SCEP protocol. +// SCEP protocol overview: https://www.cisco.com/c/en/us/support/docs/security-vpn/public-key-infrastructure-pki/116167-technote-scep-00.html +type Client struct { + // signingKey is a key which will hold the private key of the cert. + signingKey SigningKey + // commonName is the CN of the certificate request (required) + commonName string + // scepChallenge: SCEP challenge password, which could be static or dynamic. + scepChallenge string + // scepURL: The URL of the SCEP server which supports the SCEP protocol (required) + scepURL string + timeout *time.Duration + logger zerolog.Logger + + insecure bool + rootCA string +} + +// Option is a functional option for configuring a SCEP Client +type Option func(*Client) + +// WithSigningKey sets the private key signer for the certificate request. +func WithSigningKey(key SigningKey) Option { + return func(c *Client) { + c.signingKey = key + } +} + +// WithRootCA sets the root CA file to use when connecting to the SCEP server. +func WithRootCA(rootCA string) Option { + return func(c *Client) { + c.rootCA = rootCA + } +} + +// WithLogger sets the logger for the Client +func WithLogger(logger zerolog.Logger) Option { + return func(c *Client) { + c.logger = logger + } +} + +// WithChallenge sets the SCEP challenge password +func WithChallenge(challenge string) Option { + return func(c *Client) { + c.scepChallenge = challenge + } +} + +// WithURL sets the SCEP server URL +func WithURL(url string) Option { + return func(c *Client) { + c.scepURL = url + } +} + +// WithCommonName sets the common name for the certificate request +func WithCommonName(commonName string) Option { + return func(c *Client) { + c.commonName = commonName + } +} + +// WithTimeout configures the timeout for SCEP client requests. +func WithTimeout(timeout *time.Duration) Option { + return func(c *Client) { + c.timeout = timeout + } +} + +// Insecure configures the client to not verify server certificates. +// Only used for tests. +func Insecure() Option { + return func(c *Client) { + c.insecure = true + } +} + +// NewClient creates a new SCEP client with the provided options +func NewClient(opts ...Option) (*Client, error) { + // Create client with default options + c := &Client{ + logger: zerolog.Nop(), + } + + // Apply options + for _, opt := range opts { + opt(c) + } + + if c.timeout == nil { + // Set a sane default for the timeout. + c.timeout = ptr.Duration(30 * time.Second) + } + + // Check that required options are set. + // SCEP challenge is optional since the SCEP server could allow an empty challenge. + if c.scepURL == "" || c.commonName == "" || c.signingKey == nil { + return nil, errors.New("required SCEP client options not set") + } + + // Set up logger with component tag + c.logger = c.logger.With().Str("component", "scep").Logger() + + return c, nil +} + +// FetchCert fetches and returns a certificate using the SCEP protocol. +func (c *Client) FetchCert(ctx context.Context) (*x509.Certificate, error) { + // We assume the required fields have already been validated by the NewClient factory. + + kitLogger := &zerologAdapter{logger: c.logger} + opts := []scepclient.Option{ + scepclient.WithTimeout(c.timeout), + scepclient.WithRootCA(c.rootCA), + } + if c.insecure { + opts = append(opts, scepclient.Insecure()) + } + + scepClient, err := scepclient.New(c.scepURL, kitLogger, opts...) + if err != nil { + return nil, fmt.Errorf("create SCEP client: %w", err) + } + resp, _, err := scepClient.GetCACert(ctx, "") + if err != nil { + return nil, fmt.Errorf("get CA cert: %w", err) + } + caCert, err := x509.ParseCertificates(resp) + if err != nil { + return nil, fmt.Errorf("parse CA cert: %w", err) + } + + signer, err := c.signingKey.Signer() + if err != nil { + return nil, fmt.Errorf("get signer: %w", err) + } + + // Create a temporary RSA key pair in memory for SCEP envelope decryption + // ECC keys cannot be used for decryption, so we need RSA for this purpose + tempRSAKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("generate temporary RSA key: %w", err) + } + + // Generate CSR using signing key + csrTemplate := x509util.CertificateRequest{ + CertificateRequest: x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: c.commonName, + }, + // Currently, signer.Public() will always be of type *ecdsa.PublicKey. + SignatureAlgorithm: x509.ECDSAWithSHA256, + }, + ChallengePassword: c.scepChallenge, + } + + csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, signer) + if err != nil { + return nil, fmt.Errorf("create CSR: %w", err) + } + csr, err := x509.ParseCertificateRequest(csrDerBytes) + if err != nil { + return nil, fmt.Errorf("parse CSR: %w", err) + } + + // Create a self-signed certificate for SCEP protocol using the temporary RSA key + // The SCEP protocol requires RSA for both signing and decryption + // The actual CSR will be signed with the ECC key. + deviceCertificateTemplate := x509.Certificate{ + Subject: pkix.Name{ + CommonName: c.commonName, + Organization: csr.Subject.Organization, + }, + + // The server will set these on the final certificate, + // but we need to set them otherwise the CSR is rejected. + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + deviceCertificateDerBytes, err := x509.CreateCertificate( + rand.Reader, + &deviceCertificateTemplate, + &deviceCertificateTemplate, + &tempRSAKey.PublicKey, + tempRSAKey, + ) + if err != nil { + return nil, fmt.Errorf("create device certificate: %w", err) + } + + deviceCertificateForRequest, err := x509.ParseCertificate(deviceCertificateDerBytes) + if err != nil { + return nil, fmt.Errorf("parse device certificate: %w", err) + } + + // Send PKCSReq message to SCEP server + // Use RSA key for SCEP protocol (signing and decryption) + // The CSR itself was already signed with the signing key. + pkiMsgReq := &scep.PKIMessage{ + MessageType: scep.PKCSReq, + Recipients: caCert, + SignerKey: tempRSAKey, // Use RSA key for SCEP protocol + SignerCert: deviceCertificateForRequest, + CSRReqMessage: &scep.CSRReqMessage{ + ChallengePassword: c.scepChallenge, + }, + } + + msg, err := scep.NewCSRRequest(csr, pkiMsgReq, scep.WithLogger(kitLogger)) + if err != nil { + return nil, fmt.Errorf("create CSR request: %w", err) + } + + respBytes, err := scepClient.PKIOperation(ctx, msg.Raw) + if err != nil { + return nil, fmt.Errorf("do CSR request: %w", err) + } + + pkiMsgResp, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(kitLogger), scep.WithCACerts(msg.Recipients)) + if err != nil { + return nil, fmt.Errorf("parse PKIMessage response: %w", err) + } + + if pkiMsgResp.PKIStatus != scep.SUCCESS { + return nil, fmt.Errorf("PKIMessage CSR request failed with code: %s, fail info: %s", pkiMsgResp.PKIStatus, pkiMsgResp.FailInfo) + } + + // Use the temporary RSA key for decryption (ECC keys don't support decryption) + if err := pkiMsgResp.DecryptPKIEnvelope(deviceCertificateForRequest, tempRSAKey); err != nil { + return nil, fmt.Errorf("decrypt PKI envelope: %w", err) + } + + c.logger.Info().Msg("SCEP enrollment successful") + return pkiMsgResp.CertRepMessage.Certificate, nil +} + +// zerologAdapter adapts zerolog.Logger to kit/log.Logger +type zerologAdapter struct { + logger zerolog.Logger +} + +// Log implements the kit/log.Logger interface +func (a *zerologAdapter) Log(keyvals ...interface{}) error { + // Convert key-value pairs to a map + fields := make(map[string]interface{}) + for i := 0; i < len(keyvals); i += 2 { + if i+1 < len(keyvals) { + key, ok := keyvals[i].(string) + if ok { + fields[key] = keyvals[i+1] + } + } + } + + // Extract message if present + msg := "" + if msgVal, ok := fields["msg"]; ok { + if msgStr, ok := msgVal.(string); ok { + msg = msgStr + delete(fields, "msg") + } + } + + // Log with zerolog + event := a.logger.Info() + for k, v := range fields { + event = event.Interface(k, v) + } + event.Msg(msg) + + return nil +} + +func PublicKeysEqual(a, b crypto.PublicKey) (bool, error) { + derA, err := x509.MarshalPKIXPublicKey(a) + if err != nil { + return false, fmt.Errorf("marshal a: %w", err) + } + derB, err := x509.MarshalPKIXPublicKey(b) + if err != nil { + return false, fmt.Errorf("marshal b: %w", err) + } + return bytes.Equal(derA, derB), nil +} diff --git a/ee/orbit/pkg/scep/scep_test.go b/ee/orbit/pkg/scep/scep_test.go new file mode 100644 index 0000000000..de301fa347 --- /dev/null +++ b/ee/orbit/pkg/scep/scep_test.go @@ -0,0 +1,258 @@ +package scep + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + _ "embed" + "io" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" + filedepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot/file" + scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" + "github.com/fleetdm/fleet/v4/server/ptr" + kitlog "github.com/go-kit/log" + "github.com/gorilla/mux" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + challengePassword = "test-challenge" +) + +// TestNewClientValidation tests the validation of parameters in the NewClient function +func TestNewClientValidation(t *testing.T) { + signingKey, err := newSigningKey() + require.NoError(t, err, "Failed to create test signing key") + + // Define test cases + testCases := []struct { + name string + options []Option + expectError bool + errorMsg string + }{ + { + name: "missing common name", + options: []Option{ + WithSigningKey(signingKey), + WithURL("https://example.com/scep"), + WithChallenge("test-challenge"), + WithTimeout(ptr.Duration(5 * time.Second)), + }, + expectError: true, + errorMsg: "should fail without commonName", + }, + { + name: "missing URL", + options: []Option{ + WithSigningKey(signingKey), + WithURL(""), + WithChallenge("test-challenge"), + WithCommonName("test-device"), + }, + expectError: true, + errorMsg: "should fail with empty URL", + }, + { + name: "missing TEE", + options: []Option{ + WithURL("https://example.com/scep"), + WithChallenge("test-challenge"), + WithCommonName("test-device"), + WithTimeout(ptr.Duration(5 * time.Second)), + }, + expectError: true, + errorMsg: "should fail without TEE", + }, + { + name: "all required parameters", + options: []Option{ + WithSigningKey(signingKey), + WithURL("https://example.com/scep"), + WithChallenge("test-challenge"), + WithCommonName("test-device"), + WithTimeout(ptr.Duration(5 * time.Second)), + }, + expectError: false, + errorMsg: "should succeed with all required parameters", + }, + } + + // Run test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + client, err := NewClient(tc.options...) + if tc.expectError { + require.Error(t, err, "NewClient "+tc.errorMsg) + require.Nil(t, client, "Client should be nil when error occurs") + } else { + require.NoError(t, err, "NewClient "+tc.errorMsg) + require.NotNil(t, client, "Client should not be nil when no error occurs") + } + }) + } +} + +// TestClient_FetchCert tests the successful retrieval of a certificate using SCEP. +func TestClient_FetchCert(t *testing.T) { + // Start a test SCEP server + scepServer := StartTestSCEPServer(t) + defer scepServer.Close() + + // Create a logger for testing + logger := zerolog.New(zerolog.NewTestWriter(t)) + + t.Run("successful fetch", func(t *testing.T) { + signingKey, err := newSigningKey() + require.NoError(t, err, "Failed to create test TEE") + + // Create a SCEP client with all required parameters + client, err := NewClient( + WithSigningKey(signingKey), + WithURL(scepServer.URL+"/scep"), + WithChallenge(challengePassword), + WithLogger(logger), + WithTimeout(ptr.Duration(5*time.Second)), + WithCommonName("test-device"), + ) + require.NoError(t, err, "NewClient should succeed with all required parameters") + + // Fetch and save the certificate + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + clientCert, err := client.FetchCert(ctx) + require.NoError(t, err, "FetchCert should succeed") + require.NotNil(t, clientCert) + + // + // Verify certificate content + // + + require.Equal(t, "test-device", clientCert.Subject.CommonName, "Certificate should have the correct common name") + // Verify the certificate has the correct ExtKeyUsage + assert.Contains(t, clientCert.ExtKeyUsage, x509.ExtKeyUsageClientAuth, "Certificate should have ExtKeyUsageClientAuth") + // Verify the certificate was signed by the CA (the CA in our test uses RSA) + // The CSR was signed with our ECC key, but the final certificate is signed by the CA + assert.Equal(t, x509.SHA256WithRSA, clientCert.SignatureAlgorithm, "Certificate should be signed by CA with RSA") + }) + + t.Run("bad challenge password", func(t *testing.T) { + signingKey, err := newSigningKey() + require.NoError(t, err, "Failed to create test TEE") + + // Create a SCEP client with all required parameters + client, err := NewClient( + WithSigningKey(signingKey), + WithURL(scepServer.URL+"/scep"), + WithChallenge("BAD"), + WithLogger(logger), + WithTimeout(ptr.Duration(5*time.Second)), + WithCommonName("test-device"), + ) + require.NoError(t, err, "NewClient should succeed with all required parameters") + _, err = client.FetchCert(t.Context()) + assert.ErrorContains(t, err, "PKIMessage CSR request failed", "FetchAndSaveCert should fail with bad challenge password") + }) +} + +//go:embed testdata/ca.crt +var caCert []byte + +//go:embed testdata/ca.key +var caKey []byte + +//go:embed testdata/ca.pem +var caPem []byte + +func StartTestSCEPServer(t *testing.T) *httptest.Server { + caDir := t.TempDir() + if err := os.WriteFile(filepath.Join(caDir, "ca.crt"), caCert, 0o644); err != nil { + t.Fatalf("failed to write ca.crt: %v", err) + } + if err := os.WriteFile(filepath.Join(caDir, "ca.key"), caKey, 0o644); err != nil { + t.Fatalf("failed to write ca.key: %v", err) + } + if err := os.WriteFile(filepath.Join(caDir, "ca.pem"), caPem, 0o644); err != nil { + t.Fatalf("failed to write ca.pem: %v", err) + } + + newSCEPServer := func(t *testing.T) *httptest.Server { + var server *httptest.Server + t.Cleanup(func() { + if server != nil { + server.Close() + } + }) + + certDepot, err := filedepot.NewFileDepot(caDir) + if err != nil { + t.Fatal(err) + } + crt, key, err := certDepot.CA([]byte{}) + if err != nil { + t.Fatal(err) + } + + signer := scepserver.StaticChallengeMiddleware(challengePassword, scepserver.SignCSRAdapter(depot.NewSigner(certDepot))) + svc, err := scepserver.NewService(crt[0], key, signer) + if err != nil { + t.Fatal(err) + } + logger := kitlog.NewNopLogger() + e := scepserver.MakeServerEndpoints(svc) + scepHandler := scepserver.MakeHTTPHandler(e, svc, logger) + r := mux.NewRouter() + r.Handle("/scep", scepHandler) + server = httptest.NewServer(r) + return server + } + scepServer := newSCEPServer(t) + return scepServer +} + +// testKey implements the SigningKey interface for testing. +type testSigningKey struct { + key *ecdsa.PrivateKey +} + +// testSigner implements crypto.Signer for testing +type testSigner struct { + key *ecdsa.PrivateKey +} + +// newSigningKey creates a new test signing key implementation with an ECC P-384 key +func newSigningKey() (*testSigningKey, error) { + // Create ECC P-384 key in memory + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + return nil, err + } + + return &testSigningKey{key: key}, nil +} + +// Signer implements securehw.Key.Signer +func (k *testSigningKey) Signer() (crypto.Signer, error) { + return &testSigner{key: k.key}, nil +} + +// Public implements crypto.Signer.Public +func (s *testSigner) Public() crypto.PublicKey { + return &s.key.PublicKey +} + +// Sign implements crypto.Signer.Sign +func (s *testSigner) Sign(rand io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { + return ecdsa.SignASN1(rand, s.key, digest) +} diff --git a/ee/orbit/pkg/scep/testdata/ca.crt b/ee/orbit/pkg/scep/testdata/ca.crt new file mode 100644 index 0000000000..037b296cde --- /dev/null +++ b/ee/orbit/pkg/scep/testdata/ca.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFODCCAyCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAtMQwwCgYDVQQGEwNVU0Ex +EDAOBgNVBAoTB2V0Y2QtY2ExCzAJBgNVBAsTAkNBMB4XDTE2MDUyOTEzNDcwNVoX +DTI2MDUyOTEzNDcwOFowLTEMMAoGA1UEBhMDVVNBMRAwDgYDVQQKEwdldGNkLWNh +MQswCQYDVQQLEwJDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALEG +S866Uf79znmx8+BakJ17tox8VYem0NZzPc2jF4RVWXfT481Yz9jdsjZubMCFuJiI +JzpMBT7RzXvZvuzMzZEe77Tb0mM+83t5kVwWWuxkEz7HQn0tWxuLR7NGaAi5MH53 +pcSGRNH8RgC7WdhyQ/3HwNGWObe0wQT69tfz1pHDSvNR9v7DS9KIiGsMc+dcqayz +n3YQuwEV8nD1KGenxEFjFh0NsP5FKrzDrsvzdFOWLJ3jedfDCSQSe0y33syZIYAQ +wS2/b+io6GMWDQemcirN9QiI1NGkcN9zioPRuYPxkaxGNa0O+3cTgA8egTFMigvI +4ZFsmERfZkJM4sBMK1uUmxXKb87nA1zooPvPk1KGQChXBEnrkHPbkP1VO+yYOS4m +t9LDweGVS6GoC5vjqQgymOHecaNfKpBnU6t7fP/aEZUF+6mxRKofolR/hTknkVNc +q2nrXEJpz8J73Iq8rkL0rNAEu1h83npPAoUgdFhwHzlq9ShRbz+ZQTxdAv5MOVs+ +6F9qcmbv/6C4xc1N1xH2NAJ8aFZTxsw4ny43hi7DgyRh1LJxcb2Bp7JMaD56CMSA +0zJqxIiV5kGUwbmrBjXMyvjYzx/0qI3j3bZl3p8BjZgyjkvOP0nArP3bby5mEUYx +i7+YgPm8dfGIzPh19I4oFReszOJl+JrdLnbf45efAgMBAAGjYzBhMA4GA1UdDwEB +/wQEAwICBDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBT6XD/PBaV7GbFEnxOm +3OJ3deamkzAfBgNVHSMEGDAWgBT6XD/PBaV7GbFEnxOm3OJ3deamkzANBgkqhkiG +9w0BAQsFAAOCAgEAC6yBHrRElZ7ovDrqjVBf8fLG+nINETPJ/kPTlTNtvqClLaeE +NKPH6JVp0/uusoKmqvE0LxyBEdP7waHQVq2XnfYggDCNjAUFxdv7OKAwlBjJ0JGs +5RsJ9DEehyLecnDDDhte92M2xUcfMet1BmuizLDDKaUU17sI1g/UNE+c7hViZA2J +e+wezVOUZqCY0pICsm4ar8JBY/pfUZ+1J00AZJtXuVWqK5GYGkrLZ7ZjNzzDF0cY +UmJxki5rj11XpCCQOZjVB+Pp3t7YpUOey1EC+1fKKrdS40zaRS3VVgh+Guavs5HV +egBzKDQUuRrZDbodJSv28RYlVbFTmkl3hGGNE0l2v0L2XHasZHoBkDZzz9nLuiI8 +ZdhWS+fn7dbswN9WzzB+dPzKS1WkTj5RXL/luI/7+fYNQyvIJYdnNCegyi2C2yTD +a/vmFJkBU+uLHWsW9a8R5Ca7A91ltJobTJE3uwxdXuZMTrmlWKsEbhqHCqO7d0j8 +IgYGxDo9ysfA4AOiNDxlp7lXxV/JFOsuGXNdFKcDFykLZ5u21X9ho9fptWJDP9JN +NNOXjC0Jv2UGZrHze6IqyL5JqxOGpK22PQIwpZwExwijUom+LH5VEXK1zpXzwC93 +WXWVtGOW4yEqv0VTn7vafIeM5GBTJ44ggpkp4RpFWoBMZcAFj8gE/9AUaHo= +-----END CERTIFICATE----- diff --git a/ee/orbit/pkg/scep/testdata/ca.key b/ee/orbit/pkg/scep/testdata/ca.key new file mode 100644 index 0000000000..1614d5541c --- /dev/null +++ b/ee/orbit/pkg/scep/testdata/ca.key @@ -0,0 +1,54 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: DES-EDE3-CBC,85186376af1462c4 + +jY1gAV22U2GeDZW0cVFw41uABS7fe7zKQen4aQQvkFJ5DPRlZFMI2qZ2MI1Oo265 +ek1Of/pcV52ct139NuVg9JZwkPPog40xUDn72IanZ2ZvJl/dcoHFn99T816Hu0p7 +YGKpvyyy3VYuxaarZV2aUNFye/o4bnAh4P0db6qa7/sGAKAhJ9PusNcSAXWWMz1l +QSCkdD30KrZOtf39StVnNSf2vPWAjAR/w3fEOVKqEehANP7yptDVOthiqrN+p58Q +kOGf3RnA5BJ21EY09W8rbgKE18EPI0UH9LVZEvRabZd4e/VSRNL6leNO7AiMLqA0 +3P7DAvjQDeTpYu3tZNWKFlmXv28KjwNo/4mclQTM8k5nkpQcLhGEVJanMlz0NAvZ ++BOgHGt1Vd8fXp5vBEMIz0tj7jJJnyEyd6tLRc7Pm7GaFjRy70rr/ZCO27HnKeWi +BVwFmZqG6Bcc1WvOGH/w549q/Xq1B3EgjSCShd7WQqINXMFOLJRg+MVZ9/EgWTrT +AEMkWwozb7hDJ56IdjE6PUop/5nbH87YjXHreV4kaGxgz3xD+sj6MGl8uw2JeT7b +6mFrK2d+704NU0Z56w8i0ydYeNn1uFmZqL9alYTDmAjOORXAR/ApvY6ctPXSpPTW +bXgv7LNWbcD8cNWuf/24dpI+kxbrIsKdGmucjQQ066Ce9qa2Kr7HEpH/CxxKCuBx +9KTHpb2ZZI1j6Zd9DQarEQm2D9fPaqEIq9XH46tNq8twXXEaYSTwwodSkwlovB5n +e4HlbiSuHB78ej2lQyFKquqWVYMRQ3dk5CUem/4XPF0L8dPvnifoQgBMpvJvzCG5 +BsIDQXKf0qLhQPrXwemhgY8fnZqDpuRTD6mdEXoPqvJC5L+3hzpPXHtCU94oqIbq +z4lkG1ARi9yS+WfUbXXZO95+7EBBg4lXzEZvXjqY6epUVjWCnoa873H9zfMZBuLL +XkxMQyDOnXaqYeqNsCahbdH4zuobR1SCNL4nt3iSaADaN6Lezwz8LPHxoM1kG1i6 +fvPa/uRo9aVsfWsovO+od2VmqLh1sfPZoOenZSKQsAVPYmEuV8XXVJ/B8NVvNTrt +DrfAR+vFe41liMHdTUndo8uG9/IO7JNC8u98zWjyvcr6cCukqE9H60Y9QvDgaSK5 +yD/D7B4ZAp6UjHOtD+jY1mjV+aL/2XeHJyQaDczHUKm1Vd5Um2c5f6NkZycrbtGE +7z5lR2SccnbbG6XVngYiZxdMLZCrQUnSfhke+zhzYM2Ng/7fxyz3mTyG5EYvxreU +6i0Psceh+vD9IEGHYbpRfV4Uozmk7AhEOfQN3ZDXZTA4LB5Svp7j3DcOmAGtCWGx +PWA3su4KzwrW40b5ommDhndPZNoSJfsrw2GJHV62AdfIxmAi5zvALJ31YdYvZsz2 +e8cf1Cl5oxeF/jgewEy6RTSkOUjvb0iTfVgreu4Tk/sBW37jdKhfW32INasCgEYb +0fq9DLXVcDk2neH/Sb78cE26JNXS3EtW1V4dvdkhvOjqRFP8O8vFggLi1mFQltAH +pmV143MSNkC/ikyOBahpQjGu89HZ0sLnJr2kzKf5LJTcN7kYAfxRejS7ofUByME1 +O9mrHOZGGNVNIgNesBXv42UEd0/SzwF4UKxHY72sEoTNLXliroaJORYbbvWw4GDI +91/vHKJMqMimoC37soS16wrsP/SabzusUXBayHD/PLkkmHBPV9++cO79b+HbVB0Q +6OpxBY7u6QhZnfTJv/W/InG404pumq8oz6bt7bXurbfC2QzviNHuyZ/IenbQ/y41 +K5URD3fdFYLC3OS38SSBBq32yncjJam0FOj2joUZ4iAAXSju1NSDskT8WbVy3BOq +tdTxekrxM9w98p17Og+Uf8966H2mQUIrz53Umc9V1974TVWdu0Y862ghJGSeLEbH +617VGwNN9hINdQE+iYaAVvbogEKSdCfljyVdIx1MuS1jeae5wUgReqqE+bopYgJm +oIXlVNI7tWX2y3JdG1vqCqKpq/UDzciLxAUdyGgwZESt9T3mvqQcdvWxsfREBGwX +XzbiDiGoom735dOOaGxvmyZUtJi7r5AonzJpR+qaRWoHNr8cqeU9be1wxBZ57Kln +2eKpwPIwdBTwxCjnc/kstuTsR45M8G37zOgh9XK38jS6FB/FzFytHtt9oPQBZBeb +3A6p7kqbbb4ynAgDiGEz9ExNNIQf3hQo9RAiaL2WeS9FTFB02hq5QgsgrGVXrR2V +45CKzP874sMPYP8xFQvmrMAXDy//zBXaOrNJHyNOVrtDLPerBNIC6GSKtFp30ynz +Te6GHFhcwqWfrN8N1l2oM79xvc2aKlsvI+YN0xTQklxqSdyCJSdhRUxmCIMN1JM0 +13Ean0HtO+z9u/nH3T2GtAhNySJAPAOXIAAER/74WNXNJNi7SmptNtWJOKKeKK3m +Jon7XC3Bx5NTnTM6UjrrXvwXvsJyf8G+SlkoZXZx9izgQYAANAsSblieSvPVppwM +/EfU6HIby2cBLQ1wTJiEDjYu7E1JKpAPBhqL0cN7aJea9tV7bmjoqzKhbwxACHkI +ymOZ1BDIF67M5fCLFCnCZEJcl2sgx4bRBaP6+p0uRWhplrus+8x1LAtNyB+V17in +nXacPqGELgqv+F6embq03retfaCbIwLwQYmaMU+QHg9jHc9j1AIf6fHSxhIRUPUz +PWMhy7dJdUcmm2GX2EGBrr7jH+H2y33W7y+0I2a4s5WdpIWsYUMFiBU+M+qJdAwY +O/n1Q8ZPdKdY9+c2RMzeO6Zvyc7f1hwoOy0FuYi748qaELV6rx1Tr2MDWl5/uhUa +vYMF4RshsKJY9OCUKvL9waqELZf4zEPyu875ZLm9eoJV2MFcokUuPcpAN+ljj6mx +S+1O9/kRioHo7FMs9rU3bHbCMbphLc0NdI363L/sM2kSFjRWxYv87z5fEQAoZGQR +d7HePVRbp09GC9Jk9p28F6ysgqS7PwlreRRp3Dj5vFJ422QviUWTP/jLj1QfukQR +0KXZhKhs0iSmfW9vlFnADS32l67fmycHMlN9yktvzcytm6dZ/XiQMHVDhZPlIGVC +frJ2R1MhmAdFEgIPZZGuoHeXFdlYq9HMpM9lbykJ1L7M36XqaW6GgRTnhf2g4iKJ +-----END RSA PRIVATE KEY----- diff --git a/ee/orbit/pkg/scep/testdata/ca.pem b/ee/orbit/pkg/scep/testdata/ca.pem new file mode 100644 index 0000000000..037b296cde --- /dev/null +++ b/ee/orbit/pkg/scep/testdata/ca.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFODCCAyCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAtMQwwCgYDVQQGEwNVU0Ex +EDAOBgNVBAoTB2V0Y2QtY2ExCzAJBgNVBAsTAkNBMB4XDTE2MDUyOTEzNDcwNVoX +DTI2MDUyOTEzNDcwOFowLTEMMAoGA1UEBhMDVVNBMRAwDgYDVQQKEwdldGNkLWNh +MQswCQYDVQQLEwJDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALEG +S866Uf79znmx8+BakJ17tox8VYem0NZzPc2jF4RVWXfT481Yz9jdsjZubMCFuJiI +JzpMBT7RzXvZvuzMzZEe77Tb0mM+83t5kVwWWuxkEz7HQn0tWxuLR7NGaAi5MH53 +pcSGRNH8RgC7WdhyQ/3HwNGWObe0wQT69tfz1pHDSvNR9v7DS9KIiGsMc+dcqayz +n3YQuwEV8nD1KGenxEFjFh0NsP5FKrzDrsvzdFOWLJ3jedfDCSQSe0y33syZIYAQ +wS2/b+io6GMWDQemcirN9QiI1NGkcN9zioPRuYPxkaxGNa0O+3cTgA8egTFMigvI +4ZFsmERfZkJM4sBMK1uUmxXKb87nA1zooPvPk1KGQChXBEnrkHPbkP1VO+yYOS4m +t9LDweGVS6GoC5vjqQgymOHecaNfKpBnU6t7fP/aEZUF+6mxRKofolR/hTknkVNc +q2nrXEJpz8J73Iq8rkL0rNAEu1h83npPAoUgdFhwHzlq9ShRbz+ZQTxdAv5MOVs+ +6F9qcmbv/6C4xc1N1xH2NAJ8aFZTxsw4ny43hi7DgyRh1LJxcb2Bp7JMaD56CMSA +0zJqxIiV5kGUwbmrBjXMyvjYzx/0qI3j3bZl3p8BjZgyjkvOP0nArP3bby5mEUYx +i7+YgPm8dfGIzPh19I4oFReszOJl+JrdLnbf45efAgMBAAGjYzBhMA4GA1UdDwEB +/wQEAwICBDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBT6XD/PBaV7GbFEnxOm +3OJ3deamkzAfBgNVHSMEGDAWgBT6XD/PBaV7GbFEnxOm3OJ3deamkzANBgkqhkiG +9w0BAQsFAAOCAgEAC6yBHrRElZ7ovDrqjVBf8fLG+nINETPJ/kPTlTNtvqClLaeE +NKPH6JVp0/uusoKmqvE0LxyBEdP7waHQVq2XnfYggDCNjAUFxdv7OKAwlBjJ0JGs +5RsJ9DEehyLecnDDDhte92M2xUcfMet1BmuizLDDKaUU17sI1g/UNE+c7hViZA2J +e+wezVOUZqCY0pICsm4ar8JBY/pfUZ+1J00AZJtXuVWqK5GYGkrLZ7ZjNzzDF0cY +UmJxki5rj11XpCCQOZjVB+Pp3t7YpUOey1EC+1fKKrdS40zaRS3VVgh+Guavs5HV +egBzKDQUuRrZDbodJSv28RYlVbFTmkl3hGGNE0l2v0L2XHasZHoBkDZzz9nLuiI8 +ZdhWS+fn7dbswN9WzzB+dPzKS1WkTj5RXL/luI/7+fYNQyvIJYdnNCegyi2C2yTD +a/vmFJkBU+uLHWsW9a8R5Ca7A91ltJobTJE3uwxdXuZMTrmlWKsEbhqHCqO7d0j8 +IgYGxDo9ysfA4AOiNDxlp7lXxV/JFOsuGXNdFKcDFykLZ5u21X9ho9fptWJDP9JN +NNOXjC0Jv2UGZrHze6IqyL5JqxOGpK22PQIwpZwExwijUom+LH5VEXK1zpXzwC93 +WXWVtGOW4yEqv0VTn7vafIeM5GBTJ44ggpkp4RpFWoBMZcAFj8gE/9AUaHo= +-----END CERTIFICATE----- diff --git a/ee/orbit/pkg/securehw/example_linux_test.go b/ee/orbit/pkg/securehw/example_linux_test.go new file mode 100644 index 0000000000..8c88779349 --- /dev/null +++ b/ee/orbit/pkg/securehw/example_linux_test.go @@ -0,0 +1,92 @@ +//go:build linux + +package securehw_test + +import ( + "crypto" + "crypto/rand" + "crypto/sha256" + "fmt" + "log" + "os" + "testing" + + "github.com/fleetdm/fleet/v4/ee/orbit/pkg/securehw" + "github.com/rs/zerolog" +) + +func TestExampleTPM20Linux(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("Test needs to be run as root") + } + if _, err := os.Stat("/dev/tpmrm0"); err != nil { + t.Skip("Could not read TPM 2.0 device") + } + + logger := zerolog.New(zerolog.NewConsoleWriter()).With().Timestamp().Logger() + tmpDir := t.TempDir() + + t.Run("CreateKey", func(t *testing.T) { + teeDevice, err := securehw.New(tmpDir, logger) + if err != nil { + log.Fatalf("Failed to initialize TEE: %v", err) + } + defer teeDevice.Close() + + // Create an ECC key in the TEE (automatically selects best curve) + key, err := teeDevice.CreateKey() + if err != nil { + log.Fatalf("Failed to create key: %v", err) + } + defer key.Close() + + // Get a signer for the key + signer, err := key.Signer() + if err != nil { + log.Fatalf("Failed to get signer: %v", err) + } + + // Sign some data + message := []byte("Hello, TEE!") + hash := sha256.Sum256(message) + signature, err := signer.Sign(rand.Reader, hash[:], crypto.SHA256) + if err != nil { + log.Fatalf("Failed to sign: %v", err) + } + + fmt.Printf("Signature created: %x\n", signature) + }) + + t.Run("LoadKey", func(t *testing.T) { + teeDevice, err := securehw.New(tmpDir, logger) + if err != nil { + log.Fatalf("Failed to initialize TEE: %v", err) + } + defer teeDevice.Close() + + // Later, load the key back from the saved blobs + key, err := teeDevice.LoadKey() + if err != nil { + log.Fatalf("Failed to load key: %v", err) + } + defer key.Close() + + fmt.Println("Key successfully loaded") + + // Get a signer for the key + signer, err := key.Signer() + if err != nil { + log.Fatalf("Failed to get signer: %v", err) + } + + // Sign some data + message := []byte("Hello, TEE!") + hash := sha256.Sum256(message) + signature, err := signer.Sign(rand.Reader, hash[:], crypto.SHA256) + if err != nil { + log.Fatalf("Failed to sign: %v", err) + } + + fmt.Printf("Signature created: %x\n", signature) + }) +} diff --git a/ee/orbit/pkg/securehw/securehw.go b/ee/orbit/pkg/securehw/securehw.go new file mode 100644 index 0000000000..ae057b8951 --- /dev/null +++ b/ee/orbit/pkg/securehw/securehw.go @@ -0,0 +1,84 @@ +// Package securehw contains implementations of hardware-based cryptographic interfaces. +package securehw + +import ( + "crypto" + + "github.com/rs/zerolog" +) + +// TEE (Trusted Execution Environment) provides an interface for hardware-based +// cryptographic operations, such as those performed by a TPM (Trusted Platform Module). +type TEE interface { + // CreateKey creates a new key in the TEE and returns a handle to it. + // The implementation will automatically choose the best available key type, + // preferring ECC P-384 if supported, otherwise falling back to ECC P-256. + // Returns a Key interface that can be used for cryptographic operations. + CreateKey() (Key, error) + + // LoadKey loads a previously created key from the public and private blobs saved to files. + // The blobs are read from the file paths configured when creating the TEE instance. + // The parent key is the hardcoded Storage Root Key (SRK) handle. + LoadKey() (Key, error) + + // Close releases any resources held by the TEE. + Close() error +} + +// Key represents a key stored in a TEE that can perform cryptographic operations. +type Key interface { + // Signer returns a crypto.Signer that uses this key for signing operations. + // The returned Signer is safe for concurrent use. + Signer() (crypto.Signer, error) + + // HTTPSigner returns a crypto.Signer configured for RFC 9421-compatible HTTP signatures. + // The returned Signer produces fixed-width r||s format signatures. + HTTPSigner() (HTTPSigner, error) + + // Public returns the public key associated with this TEE key. + Public() (crypto.PublicKey, error) + + // Close releases any resources associated with this key. + Close() error +} + +type HTTPSigner interface { + crypto.Signer + ECCAlgorithm() ECCAlgorithm +} + +type ECCAlgorithm int + +const ( + ECCAlgorithmP256 ECCAlgorithm = iota + 1 + ECCAlgorithmP384 +) + +func New(metadataDir string, logger zerolog.Logger) (TEE, error) { + logger = logger.With().Str("component", "securehw").Logger() + return newTEE(metadataDir, logger) +} + +// ErrKeyNotFound is returned when attempting to load a key that doesn't exist. +type ErrKeyNotFound struct { + Message string +} + +func (e ErrKeyNotFound) Error() string { + if e.Message != "" { + return e.Message + } + return "key not found in TPM/TEE" +} + +// ErrTEEUnavailable is returned when the TEE hardware is not available. +type ErrTEEUnavailable struct { + Message string +} + +func (e ErrTEEUnavailable) Error() string { + if e.Message != "" { + return e.Message + } + return "secure hardware not available" +} diff --git a/ee/orbit/pkg/securehw/securehw_linux.go b/ee/orbit/pkg/securehw/securehw_linux.go new file mode 100644 index 0000000000..d77a715ab9 --- /dev/null +++ b/ee/orbit/pkg/securehw/securehw_linux.go @@ -0,0 +1,632 @@ +//go:build linux + +package securehw + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "encoding/asn1" + "errors" + "fmt" + "io" + "math/big" + "os" + "path/filepath" + + "github.com/google/go-tpm/tpm2" + "github.com/google/go-tpm/tpm2/transport" + "github.com/google/go-tpm/tpm2/transport/linuxtpm" + "github.com/rs/zerolog" +) + +// tpm2TEE implements the TEE interface using TPM 2.0. +type tpm2TEE struct { + device transport.TPMCloser + + logger zerolog.Logger + publicBlobPath string + privateBlobPath string +} + +const tpm20DevicePath = "/dev/tpmrm0" + +// Creates a new TEE instance using TPM 2.0. +// It attempts to open the TPM device using the provided configuration. +func newTEE(metadataDir string, logger zerolog.Logger) (TEE, error) { + if metadataDir == "" { + return nil, errors.New("required metadata directory not set") + } + + logger.Info().Msg("initializing TPM 2.0 connection") + + // Open the TPM 2.0 resource manager, which + // - Provides managed access to TPM resources, allowing multiple applications to share the TPM safely. + // - Used by the TPM2 Access Broker and Resource Manager (tpm2-abrmd or the kernel resource manager). + device, err := linuxtpm.Open(tpm20DevicePath) + if err != nil { + return nil, ErrTEEUnavailable{ + Message: fmt.Sprintf("failed to open TPM 2.0 device %q: %s", tpm20DevicePath, err.Error()), + } + } + + logger.Info().Str("device_path", tpm20DevicePath).Msg("successfully opened TPM 2.0 device") + + return &tpm2TEE{ + device: device, + + logger: zerolog.Nop(), + publicBlobPath: filepath.Join(metadataDir, "tpm_cms_pub.blob"), + privateBlobPath: filepath.Join(metadataDir, "tpm_cms_priv.blob"), + }, nil +} + +// CreateKey partially implements TEE. +func (t *tpm2TEE) CreateKey() (Key, error) { + t.logger.Info().Msg("creating new ECC key in TPM") + + parentKeyHandle, err := t.createParentKey() + if err != nil { + return nil, fmt.Errorf("get or create TPM parent key: %w", err) + } + + curveID, curveName := t.selectBestECCCurve() + t.logger.Info().Str("curve", curveName).Msg("selected ECC curve for key creation") + + // Create an ECC key template for the child key + t.logger.Debug().Str("curve", curveName).Msg("creating ECC key template") + eccTemplate := tpm2.New2B(tpm2.TPMTPublic{ + Type: tpm2.TPMAlgECC, + NameAlg: tpm2.TPMAlgSHA256, + ObjectAttributes: tpm2.TPMAObject{ + FixedTPM: true, + FixedParent: true, + SensitiveDataOrigin: true, + UserWithAuth: true, // Required even if password is nil + SignEncrypt: true, + // We will just use this child key for signing. + // If we need encryption in the future we can create a separate key for it. + // It's usually recommended to have separate keys for signing and encryption. + Decrypt: false, + }, + Parameters: tpm2.NewTPMUPublicParms( + tpm2.TPMAlgECC, + &tpm2.TPMSECCParms{ + CurveID: curveID, + }, + ), + }) + + // Create the key under the transient parent + t.logger.Debug().Msg("creating child key") + createKey, err := tpm2.Create{ + ParentHandle: parentKeyHandle, + InPublic: eccTemplate, + }.Execute(t.device) + if err != nil { + return nil, fmt.Errorf("create child key: %w", err) + } + + t.logger.Debug().Msg("Loading created key") + loadedKey, err := tpm2.Load{ + ParentHandle: parentKeyHandle, + InPrivate: createKey.OutPrivate, + InPublic: createKey.OutPublic, + }.Execute(t.device) + if err != nil { + return nil, fmt.Errorf("load key: %w", err) + } + + t.logger.Debug(). + Str("handle", fmt.Sprintf("0x%x", loadedKey.ObjectHandle)). + Msg("key loaded successfully") + + // Save the key context + t.logger.Debug().Msg("Saving key context") + keyContext, err := tpm2.ContextSave{ + SaveHandle: loadedKey.ObjectHandle, + }.Execute(t.device) + + cleanUpOnError := func() { + flush := tpm2.FlushContext{ + FlushHandle: loadedKey.ObjectHandle, + } + _, _ = flush.Execute(t.device) + } + + if err != nil { + cleanUpOnError() + return nil, fmt.Errorf("save key context: %w", err) + } + + t.logger.Info(). + Str("handle", fmt.Sprintf("0x%x", loadedKey.ObjectHandle)). + Msg("key created and context saved successfully") + + // Write TPM blobs to files + if err := t.writeBlobsToFiles(createKey.OutPublic, createKey.OutPrivate); err != nil { + cleanUpOnError() + return nil, fmt.Errorf("write TPM blobs to files: %w", err) + } + + // Create and return the key + return &tpm2Key{ + tpm: t.device, + handle: tpm2.NamedHandle{Handle: loadedKey.ObjectHandle, Name: loadedKey.Name}, + public: createKey.OutPublic, + context: keyContext.Context, + logger: t.logger, + }, nil +} + +// createParentKey creates a transient Storage Root Key for use as a parent key +// +// NOTE: It creates the parent key deterministically so this can be called when loading a child key. +func (t *tpm2TEE) createParentKey() (tpm2.NamedHandle, error) { + t.logger.Debug().Msg("creating transient RSA 2048-bit parent key") + + // Create a parent key template with required attributes + parentTemplate := tpm2.New2B(tpm2.TPMTPublic{ + Type: tpm2.TPMAlgRSA, + NameAlg: tpm2.TPMAlgSHA256, + ObjectAttributes: tpm2.TPMAObject{ + FixedTPM: true, // bound to TPM that created it + FixedParent: true, // Required, based on manual testing + SensitiveDataOrigin: true, // key material generated internally + UserWithAuth: true, // Required, even if we use nil password + Decrypt: true, // Allows key to be used for decryption/unwrapping + Restricted: true, // Limits use to decryption of child keys + }, + Parameters: tpm2.NewTPMUPublicParms( + tpm2.TPMAlgRSA, + &tpm2.TPMSRSAParms{ + KeyBits: 2048, + Symmetric: tpm2.TPMTSymDefObject{ + Algorithm: tpm2.TPMAlgAES, + KeyBits: tpm2.NewTPMUSymKeyBits( + tpm2.TPMAlgAES, + tpm2.TPMKeyBits(128), + ), + Mode: tpm2.NewTPMUSymMode( + tpm2.TPMAlgAES, + tpm2.TPMAlgCFB, + ), + }, + }, + ), + }) + + // If this command is called multiple times with the same inPublic parameter, + // inSensitive.data, and Primary Seed, the TPM shall produce the same Primary Object. + primaryKey, err := tpm2.CreatePrimary{ + PrimaryHandle: tpm2.TPMRHOwner, + InPublic: parentTemplate, + }.Execute(t.device) + if err != nil { + return tpm2.NamedHandle{}, fmt.Errorf("create transient parent key: %w", err) + } + + t.logger.Info(). + Str("handle", fmt.Sprintf("0x%x", primaryKey.ObjectHandle)). + Msg("created transient parent key successfully") + + // Return the transient key as a NamedHandle + return tpm2.NamedHandle{ + Handle: primaryKey.ObjectHandle, + Name: primaryKey.Name, + }, nil +} + +// selectBestECCCurve checks if the TPM supports ECC P-384, otherwise returns P-256 +func (t *tpm2TEE) selectBestECCCurve() (tpm2.TPMECCCurve, string) { + t.logger.Debug().Msg("checking TPM ECC curve support") + + // Try to create a test key with P-384 to check support + // This is a more reliable method than querying capabilities + testTemplate := tpm2.New2B(tpm2.TPMTPublic{ + Type: tpm2.TPMAlgECC, + NameAlg: tpm2.TPMAlgSHA256, + ObjectAttributes: tpm2.TPMAObject{ + FixedTPM: true, + FixedParent: true, + UserWithAuth: true, // Required even if password is nil + SensitiveDataOrigin: true, + SignEncrypt: true, + Decrypt: true, + }, + Parameters: tpm2.NewTPMUPublicParms( + tpm2.TPMAlgECC, + &tpm2.TPMSECCParms{ + CurveID: tpm2.TPMECCNistP384, + }, + ), + }) + + // Try to create a primary key with P-384 to test support + testKey, err := tpm2.CreatePrimary{ + PrimaryHandle: tpm2.TPMRHOwner, + InPublic: testTemplate, + }.Execute(t.device) + if err != nil { + t.logger.Debug().Err(err).Msg("TPM does not support P-384, using P-256") + return tpm2.TPMECCNistP256, "P-256" + } + + // Clean up the test key + flush := tpm2.FlushContext{ + FlushHandle: testKey.ObjectHandle, + } + _, _ = flush.Execute(t.device) + + t.logger.Debug().Msg("TPM supports P-384") + return tpm2.TPMECCNistP384, "P-384" +} + +// writeBlobsToFiles writes the TPM public and private blobs to the specified file paths +func (t *tpm2TEE) writeBlobsToFiles(publicBlob tpm2.TPM2BPublic, privateBlob tpm2.TPM2BPrivate) error { + t.logger.Debug(). + Str("public_path", t.publicBlobPath). + Str("private_path", t.privateBlobPath). + Msg("writing TPM blobs to files") + + // Marshal the public blob + publicData := tpm2.Marshal(publicBlob) + if err := os.WriteFile(t.publicBlobPath, publicData, 0o600); err != nil { + return fmt.Errorf("write public blob to %s: %w", t.publicBlobPath, err) + } + t.logger.Debug(). + Str("path", t.publicBlobPath). + Int("size", len(publicData)). + Msg("public blob written successfully") + + // Marshal the private blob + privateData := tpm2.Marshal(privateBlob) + if err := os.WriteFile(t.privateBlobPath, privateData, 0o600); err != nil { + return fmt.Errorf("write private blob to %s: %w", t.privateBlobPath, err) + } + t.logger.Debug(). + Str("path", t.privateBlobPath). + Int("size", len(privateData)). + Msg("private blob written successfully") + + t.logger.Info(). + Str("public_path", t.publicBlobPath). + Str("private_path", t.privateBlobPath). + Msg("TPM blobs written to files successfully") + + return nil +} + +type blobs struct { + private *tpm2.TPM2BPrivate + public *tpm2.TPM2BPublic +} + +// loadTPMKeyFromBlobs attempts to load existing TPM 2.0 key blobs from expected file paths. +// +// If the key files do not exist, then it returns nil, nil. +func (t *tpm2TEE) loadTPM2KeyFromBlobs() (keys *blobs, err error) { + t.logger.Info(). + Str("public_path", t.publicBlobPath). + Str("private_path", t.privateBlobPath). + Msg("loading key from TPM blobs") + + t.logger.Debug().Str("path", t.publicBlobPath).Msg("reading public blob") + publicData, err := os.ReadFile(t.publicBlobPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrKeyNotFound{} + } + return nil, fmt.Errorf("read public blob from %s: %w", t.publicBlobPath, err) + } + + t.logger.Debug().Str("path", t.privateBlobPath).Msg("Reading private blob") + privateData, err := os.ReadFile(t.privateBlobPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrKeyNotFound{} + } + return nil, fmt.Errorf("read private blob from %s: %w", t.privateBlobPath, err) + } + + t.logger.Debug().Msg("unmarshaling TPM blobs") + public, err := tpm2.Unmarshal[tpm2.TPM2BPublic](publicData) + if err != nil { + return nil, fmt.Errorf("unmarshal public blob: %w", err) + } + + private, err := tpm2.Unmarshal[tpm2.TPM2BPrivate](privateData) + if err != nil { + return nil, fmt.Errorf("unmarshal private blob: %w", err) + } + + return &blobs{ + private: private, + public: public, + }, nil +} + +// LoadKey partially implements TEE. +func (t *tpm2TEE) LoadKey() (Key, error) { + blobs, err := t.loadTPM2KeyFromBlobs() + if err != nil { + return nil, err + } + + // Get the parent key handle. + // + // NOTE: createParentKey calls CreatePrimary which creates the parent key + // deterministically so this can be called when loadind a child key. + parentKeyHandle, err := t.createParentKey() + if err != nil { + return nil, fmt.Errorf("get parent key: %w", err) + } + + // Load the key using the parent handle. + t.logger.Debug().Uint32("parent_handle", uint32(parentKeyHandle.Handle)).Msg("loading parent key") + loadedKey, err := tpm2.Load{ + ParentHandle: parentKeyHandle, + InPrivate: *blobs.private, + InPublic: *blobs.public, + }.Execute(t.device) + if err != nil { + return nil, fmt.Errorf("load parent key: %w", err) + } + + t.logger.Debug(). + Str("handle", fmt.Sprintf("0x%x", loadedKey.ObjectHandle)). + Msg("key loaded successfully") + + // Save the key context for potential future use + t.logger.Debug().Msg("saving key context") + keyContext, err := tpm2.ContextSave{ + SaveHandle: loadedKey.ObjectHandle, + }.Execute(t.device) + if err != nil { + t.logger.Error().Err(err).Msg("failed to save key context") + flush := tpm2.FlushContext{ + FlushHandle: loadedKey.ObjectHandle, + } + _, _ = flush.Execute(t.device) + return nil, fmt.Errorf("save key context: %w", err) + } + + t.logger.Info(). + Str("handle", fmt.Sprintf("0x%x", loadedKey.ObjectHandle)). + Msg("key loaded from blobs successfully") + + return &tpm2Key{ + tpm: t.device, + handle: tpm2.NamedHandle{ + Handle: loadedKey.ObjectHandle, + Name: loadedKey.Name, + }, + public: *blobs.public, + context: keyContext.Context, + logger: t.logger, + }, nil +} + +// Close partially implements TEE. +func (t *tpm2TEE) Close() error { + t.logger.Info().Msg("closing TPM device") + if t.device != nil { + err := t.device.Close() + if err != nil { + t.logger.Error().Err(err).Msg("error closing TPM device") + return err + } + t.device = nil + t.logger.Debug().Msg("TPM device closed successfully") + } + return nil +} + +// tpm2Key implements the Key interface using TPM 2.0. +type tpm2Key struct { + tpm transport.TPMCloser + handle tpm2.NamedHandle + public tpm2.TPM2BPublic + context tpm2.TPMSContext + logger zerolog.Logger +} + +func (k *tpm2Key) Signer() (crypto.Signer, error) { + signer, _, err := k.createSigner(false) + if err != nil { + return nil, err + } + return signer, nil +} + +func (k *tpm2Key) HTTPSigner() (HTTPSigner, error) { + signer, algo, err := k.createSigner(true) + if err != nil { + return nil, err + } + return &httpSigner{ + Signer: signer, + algo: algo, + }, nil +} + +type httpSigner struct { + crypto.Signer + algo ECCAlgorithm +} + +func (h *httpSigner) ECCAlgorithm() ECCAlgorithm { + return h.algo +} + +func (k *tpm2Key) createSigner(httpsign bool) (s crypto.Signer, algo ECCAlgorithm, err error) { + // Parse public key + pub, err := k.public.Contents() + if err != nil { + return nil, 0, fmt.Errorf("get public key contents: %w", err) + } + + if pub.Type != tpm2.TPMAlgECC { + return nil, 0, errors.New("not an ECC key") + } + + eccDetail, err := pub.Parameters.ECCDetail() + if err != nil { + return nil, 0, fmt.Errorf("get ECC details: %w", err) + } + + eccUnique, err := pub.Unique.ECC() + if err != nil { + return nil, 0, fmt.Errorf("get ECC unique: %w", err) + } + + // Create crypto.PublicKey based on curve + var publicKey *ecdsa.PublicKey + switch eccDetail.CurveID { + case tpm2.TPMECCNistP256: + publicKey = &ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: new(big.Int).SetBytes(eccUnique.X.Buffer), + Y: new(big.Int).SetBytes(eccUnique.Y.Buffer), + } + algo = ECCAlgorithmP256 + case tpm2.TPMECCNistP384: + publicKey = &ecdsa.PublicKey{ + Curve: elliptic.P384(), + X: new(big.Int).SetBytes(eccUnique.X.Buffer), + Y: new(big.Int).SetBytes(eccUnique.Y.Buffer), + } + algo = ECCAlgorithmP384 + default: + return nil, 0, fmt.Errorf("unsupported ECC curve: %v", eccDetail.CurveID) + } + + return &tpm2Signer{ + tpm: k.tpm, + handle: k.handle, + publicKey: publicKey, + httpsign: httpsign, + }, algo, nil +} + +func (k *tpm2Key) Public() (crypto.PublicKey, error) { + signer, err := k.Signer() + if err != nil { + return nil, err + } + return signer.Public(), nil +} + +func (k *tpm2Key) Close() error { + if k.handle.Handle != 0 { + flush := tpm2.FlushContext{ + FlushHandle: k.handle.Handle, + } + _, err := flush.Execute(k.tpm) + k.handle = tpm2.NamedHandle{} + return err + } + return nil +} + +// tpm2Signer implements crypto.Signer using TPM 2.0. +type tpm2Signer struct { + tpm transport.TPMCloser + handle tpm2.NamedHandle + publicKey *ecdsa.PublicKey + httpsign bool // true for RFC 9421-compatible HTTP signatures, false for standard ECDSA +} + +// _ ensures tpm2Signer satisfies the crypto.Signer interface at compile time. +var _ crypto.Signer = (*tpm2Signer)(nil) + +func (s *tpm2Signer) Public() crypto.PublicKey { + return s.publicKey +} + +func (s *tpm2Signer) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + // Determine hash algorithm + var hashAlg tpm2.TPMAlgID + switch opts.HashFunc() { + case crypto.SHA256: + hashAlg = tpm2.TPMAlgSHA256 + case crypto.SHA384: + hashAlg = tpm2.TPMAlgSHA384 + default: + return nil, fmt.Errorf("unsupported hash function: %v", opts.HashFunc()) + } + + // Sign with TPM using ECDSA. + // ECC keys are used with ECDSA (Elliptic Curve Digital Signature Algorithm) for signing + sign := tpm2.Sign{ + KeyHandle: s.handle, + Digest: tpm2.TPM2BDigest{ + Buffer: digest, + }, + InScheme: tpm2.TPMTSigScheme{ + Scheme: tpm2.TPMAlgECDSA, + Details: tpm2.NewTPMUSigScheme( + tpm2.TPMAlgECDSA, + &tpm2.TPMSSchemeHash{ + HashAlg: hashAlg, + }, + ), + }, + Validation: tpm2.TPMTTKHashCheck{ + Tag: tpm2.TPMSTHashCheck, + }, + } + + rsp, err := sign.Execute(s.tpm) + if err != nil { + return nil, fmt.Errorf("TPM sign: %w", err) + } + + // Check signature type and extract ECDSA signature + if rsp.Signature.SigAlg != tpm2.TPMAlgECDSA { + return nil, fmt.Errorf("unexpected signature algorithm: %v", rsp.Signature.SigAlg) + } + + // Get the ECDSA signature + ecdsaSig, err := rsp.Signature.Signature.ECDSA() + if err != nil { + return nil, fmt.Errorf("get ECDSA signature: %w", err) + } + + if s.httpsign { + // RFC 9421-compatible HTTP signature format: fixed-width r||s + curveBits := s.publicKey.Curve.Params().BitSize + coordSize := (curveBits + 7) / 8 // bytes per coordinate + + // Allocate the output buffer + sig := make([]byte, 2*coordSize) + + // Copy R, left-padded + sigR := ecdsaSig.SignatureR.Buffer + if len(sigR) > coordSize { + return nil, fmt.Errorf("TPM ECDSA signature R too long: got %d bytes, expected max %d", len(sigR), coordSize) + } + copy(sig[coordSize-len(sigR):coordSize], sigR) + + // Copy S, left-padded + sigS := ecdsaSig.SignatureS.Buffer + if len(sigS) > coordSize { + return nil, fmt.Errorf("TPM ECDSA signature S too long: got %d bytes, expected max %d", len(sigS), coordSize) + } + copy(sig[2*coordSize-len(sigS):], sigS) + + // The final signature contains r||s, fixed-width, RFC 9421–compatible + return sig, nil + } + + // Standard ECDSA signature format for certificate signing requests + // Convert TPM signature components to ASN.1 DER format + sigR := new(big.Int).SetBytes(ecdsaSig.SignatureR.Buffer) + sigS := new(big.Int).SetBytes(ecdsaSig.SignatureS.Buffer) + + // Encode as ASN.1 DER sequence manually + type ecdsaSignature struct { + R, S *big.Int + } + return asn1.Marshal(ecdsaSignature{R: sigR, S: sigS}) +} diff --git a/ee/orbit/pkg/securehw/securehw_stub.go b/ee/orbit/pkg/securehw/securehw_stub.go new file mode 100644 index 0000000000..e38e9235d4 --- /dev/null +++ b/ee/orbit/pkg/securehw/securehw_stub.go @@ -0,0 +1,14 @@ +//go:build !linux +// +build !linux + +package securehw + +import ( + "errors" + + "github.com/rs/zerolog" +) + +func newTEE(string, zerolog.Logger) (TEE, error) { + return nil, errors.New("not implemented") +} diff --git a/ee/server/integrationtest/hostidentity/hostidentity_test.go b/ee/server/integrationtest/hostidentity/hostidentity_test.go index ec04139d57..579a4a7b33 100644 --- a/ee/server/integrationtest/hostidentity/hostidentity_test.go +++ b/ee/server/integrationtest/hostidentity/hostidentity_test.go @@ -69,7 +69,6 @@ func testGetCertAndSignReq(t *testing.T, s *Suite) { cert, eccPrivateKey := testGetCertWithCurve(t, s, elliptic.P384()) testOsqueryEnrollment(t, s, cert, eccPrivateKey) }) - } func generateRandomString(length int) string { @@ -97,7 +96,7 @@ func testGetCertWithCurve(t *testing.T, s *Suite, curve elliptic.Curve) (cert *x // Create SCEP client scepURL := fmt.Sprintf("%s/api/fleet/orbit/host_identity/scep", s.Server.URL) - scepClient, err := scepclient.New(scepURL, s.Logger, nil) + scepClient, err := scepclient.New(scepURL, s.Logger) require.NoError(t, err) // Get CA certificate @@ -275,7 +274,6 @@ func testOrbitEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPriv // Test /api/fleet/orbit/config endpoint with different signature scenarios t.Run("config endpoint signature tests", func(t *testing.T) { - testCases := []struct { name string setupRequest func() (*http.Request, error) @@ -447,7 +445,6 @@ func testOsqueryEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPr // Test /api/osquery/config endpoint with different signature scenarios t.Run("osquery config endpoint signature tests", func(t *testing.T) { - testCases := []struct { name string setupRequest func() (*http.Request, error) @@ -665,7 +662,7 @@ func testSCEPFailure(t *testing.T, s *Suite, config SCEPFailureConfig) { // Create SCEP client scepURL := fmt.Sprintf("%s/api/fleet/orbit/host_identity/scep", s.Server.URL) - scepClient, err := scepclient.New(scepURL, s.Logger, nil) + scepClient, err := scepclient.New(scepURL, s.Logger) require.NoError(t, err) // Get CA certificate @@ -984,5 +981,4 @@ func testWrongCertAuthentication(t *testing.T, s *Suite) { // Should fail because the certificate doesn't match the host identifier require.Equal(t, http.StatusUnauthorized, httpResp.StatusCode, "Enrollment with wrong certificate should fail even after other hosts are enrolled") }) - } diff --git a/ee/server/integrationtest/hostidentity/scep_rate_limit_test.go b/ee/server/integrationtest/hostidentity/scep_rate_limit_test.go index 4b62f304ad..8611ad6361 100644 --- a/ee/server/integrationtest/hostidentity/scep_rate_limit_test.go +++ b/ee/server/integrationtest/hostidentity/scep_rate_limit_test.go @@ -98,7 +98,7 @@ func requestSCEPCertificate(t *testing.T, s *Suite, hostIdentifier string) (*htt // Create SCEP client scepURL := s.Server.URL + "/api/fleet/orbit/host_identity/scep" timeout := 30 * time.Second - scepClient, err := scepclient.New(scepURL, s.Logger, &timeout) + scepClient, err := scepclient.New(scepURL, s.Logger, scepclient.WithTimeout(&timeout)) require.NoError(t, err) // Get CA certificate diff --git a/ee/server/service/hostidentity/depot/depot.go b/ee/server/service/hostidentity/depot/depot.go index e68a4ea4be..7a2b67d304 100644 --- a/ee/server/service/hostidentity/depot/depot.go +++ b/ee/server/service/hostidentity/depot/depot.go @@ -84,9 +84,6 @@ func (d *HostIdentitySCEPDepot) HasCN(cn string, allowTime int, cert *x509.Certi } // 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") diff --git a/ee/server/service/hostidentity/httpsig/httpsig.go b/ee/server/service/hostidentity/httpsig/httpsig.go index 475128ac57..230e2fb605 100644 --- a/ee/server/service/hostidentity/httpsig/httpsig.go +++ b/ee/server/service/hostidentity/httpsig/httpsig.go @@ -9,6 +9,7 @@ import ( "strconv" "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types" + "github.com/fleetdm/fleet/v4/pkg/fleethttpsig" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/go-kit/log" "github.com/go-kit/log/level" @@ -43,14 +44,7 @@ func NewHTTPSig(ds fleet.Datastore, logger log.Logger) *HTTPSig { var _ httpsig.KeyFetcher = (*HTTPSig)(nil) func (h *HTTPSig) Verifier() (*httpsig.Verifier, error) { - return httpsig.NewVerifier(h, httpsig.VerifyProfile{ - SignatureLabel: httpsig.DefaultSignatureLabel, - AllowedAlgorithms: []httpsig.Algorithm{httpsig.Algo_ECDSA_P256_SHA256, httpsig.Algo_ECDSA_P384_SHA384}, - // We are not using @target-uri in the signature so that we don't run into issues with HTTPS forwarding and proxies (http vs https). - RequiredFields: httpsig.Fields("@method", "@authority", "@path", "@query", "content-digest"), - RequiredMetadata: []httpsig.Metadata{httpsig.MetaKeyID, httpsig.MetaCreated, httpsig.MetaNonce}, - DisallowedMetadata: []httpsig.Metadata{httpsig.MetaAlgorithm}, // The algorithm should be looked up from the keyid not an explicit setting. - }) + return fleethttpsig.Verifier(h) } func (h *HTTPSig) FetchByKeyID(ctx context.Context, _ http.Header, keyID string) (httpsig.KeySpecer, error) { diff --git a/ee/server/service/hostidentity/httpsig/middleware.go b/ee/server/service/hostidentity/httpsig/middleware.go index 3979d42eab..1917fd10d3 100644 --- a/ee/server/service/hostidentity/httpsig/middleware.go +++ b/ee/server/service/hostidentity/httpsig/middleware.go @@ -10,6 +10,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" kitlog "github.com/go-kit/log" + "github.com/go-kit/log/level" ) type key int @@ -42,52 +43,58 @@ func Middleware(ds fleet.Datastore, requireSignature bool, logger kitlog.Logger) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if strings.Contains(req.URL.Path, "/api/fleet/orbit/") || - strings.Contains(req.URL.Path, "/osquery/") { - - // We do not verify the "ping" endpoint since it is used to get server capabilities and does not carry any data. - // This endpoint is unauthenticated. - if strings.HasSuffix(req.URL.Path, "/api/fleet/orbit/ping") { - next.ServeHTTP(w, req) - return - } - - // If the request does not have an HTTP message signature, we do not verify it AND - // we do not set the host identity cert in the context - if req.Header.Get("signature") == "" || req.Header.Get("signature-input") == "" { - if requireSignature { - handleError(req.Context(), w, - ctxerr.Errorf(req.Context(), "missing required HTTP message signature: path=%s", req.URL.Path), - http.StatusUnauthorized) - return - } - next.ServeHTTP(w, req) - return - } - - result, err := verifier.Verify(req) - if err != nil { - handleError(req.Context(), w, - ctxerr.Wrap(req.Context(), err, "failed to verify request signature", fmt.Sprintf("path=%s", req.URL.Path)), - http.StatusUnauthorized) - return - } - - keySpecer, ok := result.KeySpecer.(*KeySpecer) - if !ok { - handleError(req.Context(), w, - ctxerr.Errorf(req.Context(), "could not extract host identity certificate key: path=%s", req.URL.Path), - http.StatusInternalServerError) - return - } - if !result.Verified { - handleError(req.Context(), w, - ctxerr.Errorf(req.Context(), "request not verified: path=%s host_uuid=%s", req.URL.Path, keySpecer.hostIdentityCert.CommonName), - http.StatusUnauthorized) - return - } - req = req.WithContext(NewContext(req.Context(), keySpecer.hostIdentityCert)) + if !strings.Contains(req.URL.Path, "/api/fleet/orbit/") && !strings.Contains(req.URL.Path, "/osquery/") { + next.ServeHTTP(w, req) + return } + + // We do not verify the "ping" endpoint since it is used to get server capabilities and does not carry any data. + // This endpoint is unauthenticated. + if strings.HasSuffix(req.URL.Path, "/api/fleet/orbit/ping") { + next.ServeHTTP(w, req) + return + } + + // If the request does not have an HTTP message signature, we do not verify it AND + // we do not set the host identity cert in the context + if req.Header.Get("signature") == "" || req.Header.Get("signature-input") == "" { + if requireSignature { + handleError(req.Context(), w, + ctxerr.Errorf(req.Context(), "missing required HTTP message signature: path=%s", req.URL.Path), + http.StatusUnauthorized) + return + } + next.ServeHTTP(w, req) + return + } + + // Verify signature using certificate associated with the provided serial number. + result, err := verifier.Verify(req) + if err != nil { + handleError(req.Context(), w, + ctxerr.Wrap(req.Context(), err, "failed to verify request signature", fmt.Sprintf("path=%s", req.URL.Path)), + http.StatusUnauthorized) + return + } + keySpecer, ok := result.KeySpecer.(*KeySpecer) + if !ok { + handleError(req.Context(), w, + ctxerr.New(req.Context(), fmt.Sprintf("could not extract host identity certificate key: path=%s", req.URL.Path)), + http.StatusInternalServerError) + return + } + if !result.Verified { + handleError(req.Context(), w, + ctxerr.New(req.Context(), fmt.Sprintf("request not verified: path=%s host_uuid=%s", req.URL.Path, + keySpecer.hostIdentityCert.CommonName)), + http.StatusUnauthorized) + return + } + + level.Debug(logger).Log("msg", "httpsig verified", "host_id", keySpecer.hostIdentityCert.HostID) + + // Signature is valid, we set the identity data in the context and proceed with processing the request. + req = req.WithContext(NewContext(req.Context(), keySpecer.hostIdentityCert)) next.ServeHTTP(w, req) }) }, nil diff --git a/ee/server/service/scep_proxy.go b/ee/server/service/scep_proxy.go index f8bafefc69..cc43b1de77 100644 --- a/ee/server/service/scep_proxy.go +++ b/ee/server/service/scep_proxy.go @@ -62,7 +62,7 @@ func (svc *scepProxyService) GetCACaps(ctx context.Context, identifier string) ( return nil, err } - client, err := scepclient.New(scepURL, svc.debugLogger, svc.Timeout) + client, err := scepclient.New(scepURL, svc.debugLogger, scepclient.WithTimeout(svc.Timeout)) if err != nil { return nil, ctxerr.Wrap(ctx, err, "creating SCEP client") } @@ -81,7 +81,7 @@ func (svc *scepProxyService) GetCACert(ctx context.Context, message string, iden return nil, 0, err } - client, err := scepclient.New(scepURL, svc.debugLogger, svc.Timeout) + client, err := scepclient.New(scepURL, svc.debugLogger, scepclient.WithTimeout(svc.Timeout)) if err != nil { return nil, 0, ctxerr.Wrap(ctx, err, "creating SCEP client") } @@ -101,7 +101,7 @@ func (svc *scepProxyService) PKIOperation(ctx context.Context, data []byte, iden return nil, err } - client, err := scepclient.New(scepURL, svc.debugLogger, svc.Timeout) + client, err := scepclient.New(scepURL, svc.debugLogger, scepclient.WithTimeout(svc.Timeout)) if err != nil { return nil, ctxerr.Wrap(ctx, err, "creating SCEP client") } @@ -328,7 +328,7 @@ func (s *SCEPConfigService) GetNDESSCEPChallenge(ctx context.Context, proxy flee } func (s *SCEPConfigService) ValidateSCEPURL(ctx context.Context, url string) error { - client, err := scepclient.New(url, s.logger, s.Timeout) + client, err := scepclient.New(url, s.logger, scepclient.WithTimeout(s.Timeout)) if err != nil { return ctxerr.Wrap(ctx, err, "creating SCEP client; invalid SCEP URL; please correct and try again") } diff --git a/go.mod b/go.mod index cb964d65a6..b55f2079c2 100644 --- a/go.mod +++ b/go.mod @@ -246,6 +246,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-tpm v0.9.5 // indirect github.com/google/rpmpack v0.0.0-20210518075352-dc539ef4f2ea // indirect github.com/google/s2a-go v0.1.8 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect diff --git a/go.sum b/go.sum index b8dfc84900..de6c9048b4 100644 --- a/go.sum +++ b/go.sum @@ -489,6 +489,8 @@ github.com/google/go-github/v37 v37.0.0/go.mod h1:LM7in3NmXDrX58GbEHy7FtNLbI2Jij github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= +github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= diff --git a/orbit/changes/fleetd-tpm-key b/orbit/changes/fleetd-tpm-key new file mode 100644 index 0000000000..fd1c6869a2 --- /dev/null +++ b/orbit/changes/fleetd-tpm-key @@ -0,0 +1 @@ +* Added support to generate a TPM 2.0 private key and issue a SCEP certificate for signing of HTTP requests (via new environment variable `ORBIT_FLEET_MANAGED_CLIENT_CERTIFICATE`). diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index d342216665..f2a90a06d6 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -18,6 +18,7 @@ import ( "fmt" "io" "io/fs" + "net/http" "net/url" "os" "os/exec" @@ -27,6 +28,9 @@ import ( "strings" "time" + "github.com/fleetdm/fleet/v4/ee/orbit/pkg/hostidentity" + httpsigproxy "github.com/fleetdm/fleet/v4/ee/orbit/pkg/httpsigproxy" + "github.com/fleetdm/fleet/v4/ee/orbit/pkg/securehw" "github.com/fleetdm/fleet/v4/orbit/pkg/augeas" "github.com/fleetdm/fleet/v4/orbit/pkg/build" "github.com/fleetdm/fleet/v4/orbit/pkg/constant" @@ -50,12 +54,14 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/user" "github.com/fleetdm/fleet/v4/pkg/certificate" "github.com/fleetdm/fleet/v4/pkg/file" + "github.com/fleetdm/fleet/v4/pkg/fleethttpsig" retrypkg "github.com/fleetdm/fleet/v4/pkg/retry" "github.com/fleetdm/fleet/v4/pkg/secure" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/service" "github.com/google/uuid" "github.com/oklog/run" + httpsig "github.com/remitly-oss/httpsig-go" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/urfave/cli/v2" @@ -230,6 +236,11 @@ func main() { Usage: "Sets a custom osquery database directory, it must be an absolute path", EnvVars: []string{"ORBIT_OSQUERY_DB"}, }, + &cli.BoolFlag{ + Name: "fleet-managed-client-certificate", + Usage: "Configures fleetd to use TPM-backed key to sign HTTP requests. This functionality is licensed under the Fleet EE License. Usage requires a current Fleet EE subscription.", + EnvVars: []string{"ORBIT_FLEET_MANAGED_CLIENT_CERTIFICATE"}, + }, } app.Before = func(c *cli.Context) error { // handle old installations, which had default root dir set to /var/lib/orbit @@ -820,7 +831,11 @@ func main() { } var certPath string - if fleetURL != "https://" && c.Bool("insecure") { + + // Both options --fleet-managed-client-certificate and --insecure make use of a local HTTPS proxy. + // If the user sets both --fleet-managed-client-certificate and --insecure then only the proxy + // for the fleet managed client certificate will be executed. + if fleetURL != "https://" && c.Bool("insecure") && !c.Bool("fleet-managed-client-certificate") { proxy, err := insecure.NewTLSProxy(fleetURL) if err != nil { return fmt.Errorf("create TLS proxy: %w", err) @@ -924,6 +939,15 @@ func main() { return fmt.Errorf("error loading fleet client certificate: %w", err) } + if c.Bool("fleet-managed-client-certificate") { + if runtime.GOOS != "linux" { + return errors.New("fleet-managed-client-certificate is only supported on Linux") + } + if fleetClientCrt != nil { + return errors.New("fleet-managed-client-certificate for HTTP signing, and TLS client certificates may not be specified together") + } + } + var fleetClientCertificate *tls.Certificate if fleetClientCrt != nil { log.Info().Msg("Found TLS client certificate and key. Using them to authenticate to Fleet.") @@ -934,6 +958,92 @@ func main() { })) } + var ( + signerWrapper func(*http.Client) *http.Client + hostIdentityCertificatePath string + ) + if c.Bool("fleet-managed-client-certificate") { + commonName := osqueryHostInfo.HardwareUUID + if c.String("host-identifier") == "instance" { + commonName = osqueryHostInfo.InstanceID + } + hostIdentityCredentials, err := hostidentity.Setup( + c.Context, + c.String("root-dir"), + fleetURL+"/api/fleet/orbit/host_identity/scep", + c.String("enroll-secret"), + commonName, + c.String("fleet-certificate"), + c.Bool("insecure"), + log.Logger, + ) + if err != nil { + return fmt.Errorf("failed to create or load client certificate: %w", err) + } + defer hostIdentityCredentials.Close() + + log.Info().Str( + "commonName", hostIdentityCredentials.Certificate.Subject.CommonName, + ).Msg("certificate issued successfully") + + cryptoSigner, err := hostIdentityCredentials.SecureHWKey.HTTPSigner() + if err != nil { + return fmt.Errorf("error getting secure HW backed signer: %w", err) + } + + // Get serial number as hex string + certSN := strings.ToUpper(hostIdentityCredentials.Certificate.SerialNumber.Text(16)) + + // Get ECC algorithm for signing. + var signingAlgorithm httpsig.Algorithm + switch v := cryptoSigner.ECCAlgorithm(); v { + case securehw.ECCAlgorithmP256: + signingAlgorithm = httpsig.Algo_ECDSA_P256_SHA256 + case securehw.ECCAlgorithmP384: + signingAlgorithm = httpsig.Algo_ECDSA_P384_SHA384 + default: + return fmt.Errorf("invalid ECC algorithm: %v", v) + } + + httpSigner, err := fleethttpsig.Signer(certSN, cryptoSigner, signingAlgorithm) + if err != nil { + return fmt.Errorf("failed to create HTTP signer: %w", err) + } + + proxyDirectory := filepath.Join(c.String("root-dir"), "proxy") + proxy, err := httpsigproxy.NewProxy(proxyDirectory, fleetURL, c.String("fleet-certificate"), c.Bool("insecure"), httpSigner) + if err != nil { + return fmt.Errorf("create TLS proxy: %w", err) + } + + addSubsystem(&g, "httpsig localhost proxy", &wrapSubsystem{ + execute: func() error { + log.Info(). + Str("addr", proxy.ParsedURL.String()). + Str("target", fleetURL). + Msg("httpsig localhost proxy") + return proxy.Serve() + }, + interrupt: func(_ error) { + if err := proxy.Close(); err != nil { + log.Error().Err(err).Msg("close httpsig proxy") + } + }, + }) + + signerWrapper = func(client *http.Client) *http.Client { + return httpsig.NewHTTPClient(client, httpSigner, nil) + } + hostIdentityCertificatePath = hostIdentityCredentials.CertificatePath + + options = append(options, + osquery.WithFlags(osquery.FleetFlags(proxy.ParsedURL)), + + // This is overriding the previous set of --tls_server_certs in osquery.FleetFlags above. + osquery.WithFlags([]string{"--tls_server_certs", proxy.CertificatePath}), + ) + } + orbitClient, err := service.NewOrbitClient( c.String("root-dir"), fleetURL, @@ -950,7 +1060,8 @@ func main() { log.Info().Err(err).Msg("network error") }, }, - nil, + signerWrapper, + hostIdentityCertificatePath, ) if err != nil { return fmt.Errorf("error new orbit client: %w", err) @@ -1219,7 +1330,6 @@ func main() { } addSubsystem(&g, "osqueryd runner", r) - // rootDir string, addr string, rootCA string, insecureSkipVerify bool, enrollSecret, uuid string checkerClient, err := service.NewOrbitClient( c.String("root-dir"), fleetURL, @@ -1237,6 +1347,7 @@ func main() { }, }, nil, + "", ) if err != nil { return fmt.Errorf("new client for capabilities checker: %w", err) diff --git a/orbit/pkg/constant/constant.go b/orbit/pkg/constant/constant.go index 3f98ea1790..7d34ed1e7e 100644 --- a/orbit/pkg/constant/constant.go +++ b/orbit/pkg/constant/constant.go @@ -75,4 +75,6 @@ const ( DesktopTUFTargetName = "desktop" // FleetURLFileName is the file where Fleet URL is stored after being read from Apple config profile. FleetURLFileName = "fleet_url.txt" + + FleetHTTPSignatureCertificateFileName = "host_identity.crt" ) diff --git a/orbit/pkg/packaging/linux_shared.go b/orbit/pkg/packaging/linux_shared.go index 821a28a3b1..8af07e3bb6 100644 --- a/orbit/pkg/packaging/linux_shared.go +++ b/orbit/pkg/packaging/linux_shared.go @@ -333,6 +333,7 @@ ORBIT_FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST={{ .FleetDesktopAlternativeBrowserH {{ if and (ne .HostIdentifier "") (ne .HostIdentifier "uuid") }}ORBIT_HOST_IDENTIFIER={{.HostIdentifier}}{{ end }} {{ if .OsqueryDB }}ORBIT_OSQUERY_DB={{.OsqueryDB}}{{ end }} {{ if .EndUserEmail }}ORBIT_END_USER_EMAIL={{.EndUserEmail}}{{ end }} +{{ if .FleetManagedClientCertificate }}ORBIT_FLEET_MANAGED_CLIENT_CERTIFICATE=true{{ end }} `)) func writeEnvFile(opt Options, rootPath string) error { diff --git a/orbit/pkg/packaging/packaging.go b/orbit/pkg/packaging/packaging.go index 9ed65a95f7..e9cb4cc11d 100644 --- a/orbit/pkg/packaging/packaging.go +++ b/orbit/pkg/packaging/packaging.go @@ -133,6 +133,8 @@ type Options struct { NativePlatform string // CustomOutfile is the custom output file name for the package. CustomOutfile string + // FleetManagedClientCertificate configures fleetd to use TPM-backed key to sign HTTP requests. + FleetManagedClientCertificate bool } const ( diff --git a/pkg/fleethttpsig/fleethttpsig.go b/pkg/fleethttpsig/fleethttpsig.go new file mode 100644 index 0000000000..c391dfbaec --- /dev/null +++ b/pkg/fleethttpsig/fleethttpsig.go @@ -0,0 +1,40 @@ +// Package fleethttpsig is a common package to use by Fleet client and servers for HTTP signing/verification. +package fleethttpsig + +import ( + "crypto" + + "github.com/remitly-oss/httpsig-go" +) + +var ( + // requiredFields specifies the required fields in HTTP signed requests. + // We are not using @target-uri in the signature so that we don't run into issues with HTTPS forwarding and proxies (http vs https). + requiredFields = httpsig.Fields("@method", "@authority", "@path", "@query", "content-digest") + + requiredMetadata = []httpsig.Metadata{httpsig.MetaKeyID, httpsig.MetaCreated, httpsig.MetaNonce} +) + +// Verifier returns a *httpsig.Verified configured for verifying signed HTTP requests from Fleet clients. +func Verifier(kf httpsig.KeyFetcher) (*httpsig.Verifier, error) { + return httpsig.NewVerifier(kf, httpsig.VerifyProfile{ + SignatureLabel: httpsig.DefaultSignatureLabel, + AllowedAlgorithms: []httpsig.Algorithm{httpsig.Algo_ECDSA_P256_SHA256, httpsig.Algo_ECDSA_P384_SHA384}, + RequiredFields: requiredFields, + RequiredMetadata: requiredMetadata, + // The algorithm should be looked up from the keyid not an explicit setting. + DisallowedMetadata: []httpsig.Metadata{httpsig.MetaAlgorithm}, + }) +} + +// Signer returns a *httpsig.Signer to sign HTTP requests to a Fleet server. +func Signer(metaKeyID string, signer crypto.Signer, signingAlgorithm httpsig.Algorithm) (*httpsig.Signer, error) { + return httpsig.NewSigner(httpsig.SigningProfile{ + Algorithm: signingAlgorithm, + Fields: requiredFields, + Metadata: requiredMetadata, + }, httpsig.SigningKey{ + Key: signer, + MetaKeyID: metaKeyID, + }) +} diff --git a/server/mdm/scep/client/client.go b/server/mdm/scep/client/client.go index 37c43c5818..2abdbce5ae 100644 --- a/server/mdm/scep/client/client.go +++ b/server/mdm/scep/client/client.go @@ -15,13 +15,55 @@ type Client interface { Supports(capacity string) bool } +type clientOpts struct { + timeout *time.Duration + rootCA string + insecure bool +} + +// Option is a functional option for configuring a SCEP Client +type Option func(*clientOpts) + +// WithRootCA sets the root CA file to use when connecting to the SCEP server. +func WithRootCA(rootCA string) Option { + return func(c *clientOpts) { + c.rootCA = rootCA + } +} + +// Insecure configures the client to not verify server certificates. +// Only used for tests. +func Insecure() Option { + return func(c *clientOpts) { + c.insecure = true + } +} + +// WithTimeout configures the timeout for SCEP client requests. +func WithTimeout(timeout *time.Duration) Option { + return func(c *clientOpts) { + c.timeout = timeout + } +} + // New creates a SCEP Client. func New( serverURL string, logger log.Logger, - timeout *time.Duration, + opts ...Option, ) (Client, error) { - endpoints, err := scepserver.MakeClientEndpoints(serverURL, timeout) + var co clientOpts + for _, fn := range opts { + fn(&co) + } + clientOpts := []scepserver.ClientOption{ + scepserver.WithClientTimeout(co.timeout), + scepserver.WithClientRootCA(co.rootCA), + } + if co.insecure { + clientOpts = append(clientOpts, scepserver.ClientInsecure()) + } + endpoints, err := scepserver.MakeClientEndpoints(serverURL, clientOpts...) if err != nil { return nil, err } diff --git a/server/mdm/scep/cmd/scepclient/scepclient.go b/server/mdm/scep/cmd/scepclient/scepclient.go index 94fd7bca5c..39d115f17f 100644 --- a/server/mdm/scep/cmd/scepclient/scepclient.go +++ b/server/mdm/scep/cmd/scepclient/scepclient.go @@ -70,7 +70,7 @@ func run(cfg runCfg) error { } lginfo := level.Info(logger) - client, err := scepclient.New(cfg.serverURL, logger, nil) + client, err := scepclient.New(cfg.serverURL, logger) if err != nil { return err } diff --git a/server/mdm/scep/server/endpoint.go b/server/mdm/scep/server/endpoint.go index 1bd202b7a1..f76ba5f9ce 100644 --- a/server/mdm/scep/server/endpoint.go +++ b/server/mdm/scep/server/endpoint.go @@ -3,8 +3,12 @@ package scepserver import ( "bytes" "context" + "crypto/tls" + "crypto/x509" "errors" + "fmt" "net/url" + "os" "strings" "sync" "time" @@ -111,10 +115,46 @@ func MakeServerEndpointsWithIdentifier(svc ServiceWithIdentifier) *Endpoints { } } +type clientOpts struct { + timeout *time.Duration + rootCA string + insecure bool +} + +// ClientOption is a functional option for configuring a SCEP Client +type ClientOption func(*clientOpts) + +// WithClientRootCA sets the root CA file to use when connecting to the SCEP server. +func WithClientRootCA(rootCA string) ClientOption { + return func(c *clientOpts) { + c.rootCA = rootCA + } +} + +// ClientInsecure configures the client to not verify server certificates. +// Only used for tests. +func ClientInsecure() ClientOption { + return func(c *clientOpts) { + c.insecure = true + } +} + +// WithClientTimeout configures the timeout for SCEP client requests. +func WithClientTimeout(timeout *time.Duration) ClientOption { + return func(c *clientOpts) { + c.timeout = timeout + } +} + // MakeClientEndpoints returns an Endpoints struct where each endpoint invokes // the corresponding method on the remote instance, via a transport/http.Client. // Useful in a SCEP client. -func MakeClientEndpoints(instance string, timeout *time.Duration) (*Endpoints, error) { +func MakeClientEndpoints(instance string, opts ...ClientOption) (*Endpoints, error) { + var co clientOpts + for _, fn := range opts { + fn(&co) + } + if !strings.HasPrefix(instance, "http") { instance = "http://" + instance } @@ -124,9 +164,32 @@ func MakeClientEndpoints(instance string, timeout *time.Duration) (*Endpoints, e } var fleetOpts []fleethttp.ClientOpt - if timeout != nil { - fleetOpts = append(fleetOpts, fleethttp.WithTimeout(*timeout)) + if co.timeout != nil { + fleetOpts = append(fleetOpts, fleethttp.WithTimeout(*co.timeout)) } + + if co.rootCA != "" || co.insecure { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + switch { + case co.rootCA != "": + certs, err := os.ReadFile(co.rootCA) + if err != nil { + return nil, fmt.Errorf("reading root CA: %w", err) + } + rootCAPool := x509.NewCertPool() + if ok := rootCAPool.AppendCertsFromPEM(certs); !ok { + return nil, errors.New("failed to add certificates to root CA pool") + } + tlsConfig.RootCAs = rootCAPool + case co.insecure: + // Ignoring "G402: TLS InsecureSkipVerify set true", needed for development/testing. + tlsConfig.InsecureSkipVerify = true //nolint:gosec + } + fleetOpts = append(fleetOpts, fleethttp.WithTLSClientConfig(tlsConfig)) + } + options := []httptransport.ClientOption{httptransport.SetClient(fleethttp.NewClient(fleetOpts...))} return &Endpoints{ diff --git a/server/service/orbit_client.go b/server/service/orbit_client.go index de418fb6fe..452903470b 100644 --- a/server/service/orbit_client.go +++ b/server/service/orbit_client.go @@ -61,6 +61,12 @@ type OrbitClient struct { receiverUpdateContext context.Context // receiverUpdateCancelFunc is used to cancel receiverUpdateContext. receiverUpdateCancelFunc context.CancelFunc + + // hostIdentityCertPath is the file path to the host identity certificate issued using SCEP. + // + // If set then it will be deleted on HTTP 401 errors from Fleet and it will cause ExecuteConfigReceivers + // to terminate to trigger a restart. + hostIdentityCertPath string } // time-to-live for config cache @@ -168,10 +174,11 @@ func NewOrbitClient( fleetClientCert *tls.Certificate, orbitHostInfo fleet.OrbitHostInfo, onGetConfigErrFns *OnGetConfigErrFuncs, - signerWrapper func(*http.Client) *http.Client, + httpSignerWrapper func(*http.Client) *http.Client, + hostIdentityCertPath string, ) (*OrbitClient, error) { orbitCapabilities := fleet.GetOrbitClientCapabilities() - bc, err := newBaseClient(addr, insecureSkipVerify, rootCA, "", fleetClientCert, orbitCapabilities, signerWrapper) + bc, err := newBaseClient(addr, insecureSkipVerify, rootCA, "", fleetClientCert, orbitCapabilities, httpSignerWrapper) if err != nil { return nil, err } @@ -190,6 +197,7 @@ func NewOrbitClient( ReceiverUpdateInterval: defaultOrbitConfigReceiverInterval, receiverUpdateContext: ctx, receiverUpdateCancelFunc: cancelFunc, + hostIdentityCertPath: hostIdentityCertPath, }, nil } @@ -618,6 +626,14 @@ func (oc *OrbitClient) authenticatedRequest(verb string, path string, params int log.Info().Err(err).Msg("remove orbit node key") } oc.setEnrolled(false) + + if oc.hostIdentityCertPath != "" { + if err := os.Remove(oc.hostIdentityCertPath); err != nil { + log.Info().Err(err).Msg("remove orbit host identity cert") + } + log.Info().Msg("removed orbit host identity cert, triggering a restart") + oc.receiverUpdateCancelFunc() + } return err default: return err diff --git a/tools/tuf/test/create_repository.sh b/tools/tuf/test/create_repository.sh index bd09e22bc1..36e9cd08a6 100755 --- a/tools/tuf/test/create_repository.sh +++ b/tools/tuf/test/create_repository.sh @@ -23,14 +23,14 @@ fi if [[ -d "$TUF_PATH" ]]; then set +x echo "Do you want to remove the existing $TUF_PATH directory?" - echo "Type 'yes/no' to continue... " + echo "Type 'yes/no' to continue... (or Ctrl+C to exit)" while read -r word; do if [[ "$word" == "yes" ]]; then rm -rf "$TUF_PATH" break elif [[ "$word" == "no" ]]; then - break + exit 0 fi done set -x @@ -38,13 +38,13 @@ fi SYSTEMS=${SYSTEMS:-macos linux linux-arm64 windows windows-arm64} -echo "Generating packages for $SYSTEMS" +echo "Generating components for $SYSTEMS" NUDGE_VERSION=stable ESCROW_BUDDY_PKG_VERSION=1.0.0 if [[ -z "$OSQUERY_VERSION" ]]; then - OSQUERY_VERSION=5.16.0 + OSQUERY_VERSION=5.18.1 fi mkdir -p $TUF_PATH/tmp diff --git a/tools/tuf/test/gen_pkgs.sh b/tools/tuf/test/gen_pkgs.sh index 94b85b7013..c10fec1604 100755 --- a/tools/tuf/test/gen_pkgs.sh +++ b/tools/tuf/test/gen_pkgs.sh @@ -27,6 +27,7 @@ set -ex # FLEET_DESKTOP: Whether to build with Fleet Desktop support. # INSECURE: Whether to use the --insecure flag. # USE_FLEET_SERVER_CERTIFICATE: Whether to use a custom certificate bundle. +# FLEET_MANAGED_CLIENT_CERTIFICATE: Whether to use TPM-backed key for HTTP signing. # USE_UPDATE_SERVER_CERTIFICATE: Whether to use a custom certificate bundle. # FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST: Alternative host:port to use for the Fleet Desktop browser URLs. # DEBUG: Whether or not to build the package with --debug. @@ -81,6 +82,7 @@ if [ -n "$GENERATE_DEB" ]; then ${USE_UPDATE_CLIENT_CERTIFICATE:+--update-tls-client-key=./tools/test-orbit-mtls/client.key} \ ${FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST:+--fleet-desktop-alternative-browser-host=$FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST} \ ${ENABLE_SCRIPTS:+--enable-scripts} \ + ${FLEET_MANAGED_CLIENT_CERTIFICATE:+--fleet-managed-client-certificate} \ --update-url=$DEB_TUF_URL fi @@ -105,6 +107,7 @@ if [ -n "$GENERATE_DEB_ARM64" ]; then ${USE_UPDATE_CLIENT_CERTIFICATE:+--update-tls-client-key=./tools/test-orbit-mtls/client.key} \ ${FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST:+--fleet-desktop-alternative-browser-host=$FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST} \ ${ENABLE_SCRIPTS:+--enable-scripts} \ + ${FLEET_MANAGED_CLIENT_CERTIFICATE:+--fleet-managed-client-certificate} \ --update-url=$DEB_TUF_URL fi @@ -129,6 +132,7 @@ if [ -n "$GENERATE_RPM" ]; then ${USE_UPDATE_CLIENT_CERTIFICATE:+--update-tls-client-key=./tools/test-orbit-mtls/client.key} \ ${FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST:+--fleet-desktop-alternative-browser-host=$FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST} \ ${ENABLE_SCRIPTS:+--enable-scripts} \ + ${FLEET_MANAGED_CLIENT_CERTIFICATE:+--fleet-managed-client-certificate} \ --update-url=$RPM_TUF_URL fi @@ -153,6 +157,7 @@ if [ -n "$GENERATE_RPM_ARM64" ]; then ${USE_UPDATE_CLIENT_CERTIFICATE:+--update-tls-client-key=./tools/test-orbit-mtls/client.key} \ ${FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST:+--fleet-desktop-alternative-browser-host=$FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST} \ ${ENABLE_SCRIPTS:+--enable-scripts} \ + ${FLEET_MANAGED_CLIENT_CERTIFICATE:+--fleet-managed-client-certificate} \ --update-url=$RPM_TUF_URL fi