mirror of
https://github.com/fleetdm/fleet
synced 2026-05-20 23:48:52 +00:00
Fixes #30458 Contributor docs PR: https://github.com/fleetdm/fleet/pull/30651 # Checklist for submitter - We will add changes file later. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [x] Added/updated automated tests - Did not do manual QA since the SCEP client I have doesn't support ECC. Will rely on next subtasks for manual QA. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced Host Identity SCEP (Simple Certificate Enrollment Protocol) support, enabling secure host identity certificate enrollment and management. * Added new API endpoints for Host Identity SCEP, including certificate issuance and retrieval. * Implemented MySQL-backed storage and management for host identity SCEP certificates and serials. * Added new database tables for storing host identity SCEP certificates and serial numbers. * Provided utilities for encoding certificates and keys, and handling ECDSA public keys. * **Bug Fixes** * None. * **Tests** * Added comprehensive integration and unit tests for Host Identity SCEP functionality, including certificate issuance, validation, and error scenarios. * **Chores** * Updated test utilities to support unique test names and new SCEP storage options. * Extended mock datastore and interfaces for new host identity certificate methods. * **Documentation** * Added comments and documentation for new SCEP-related interfaces, methods, and database schema changes. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
259 lines
7 KiB
Go
259 lines
7 KiB
Go
// Package certificate contains functions for handling TLS certificates.
|
|
package certificate
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
)
|
|
|
|
// LoadPEM loads certificates from a PEM file and returns a cert pool containing
|
|
// the certificates.
|
|
func LoadPEM(path string) (*x509.CertPool, error) {
|
|
pool := x509.NewCertPool()
|
|
|
|
contents, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read certificate file: %w", err)
|
|
}
|
|
|
|
if ok := pool.AppendCertsFromPEM(contents); !ok {
|
|
return nil, fmt.Errorf("no valid certificates found in %s", path)
|
|
}
|
|
|
|
return pool, nil
|
|
}
|
|
|
|
// ValidateConnection checks that a connection can be successfully established
|
|
// to the server URL using the cert pool provided. The validation performed is
|
|
// not sufficient to verify authenticity of the server, but it can help to catch
|
|
// certificate errors and provide more detailed messages to users.
|
|
func ValidateConnection(pool *x509.CertPool, fleetURL string) error {
|
|
return ValidateConnectionContext(context.Background(), pool, fleetURL)
|
|
}
|
|
|
|
// ValidateConnectionContext is like ValidateConnection, but it accepts a
|
|
// context that may specify a timeout or deadline for the TLS connection check.
|
|
func ValidateConnectionContext(ctx context.Context, pool *x509.CertPool, targetURL string) error {
|
|
parsed, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "parse url")
|
|
}
|
|
|
|
dialer := &tls.Dialer{
|
|
Config: &tls.Config{
|
|
RootCAs: pool,
|
|
InsecureSkipVerify: true,
|
|
VerifyConnection: func(state tls.ConnectionState) error {
|
|
if len(state.PeerCertificates) == 0 {
|
|
return ctxerr.New(ctx, "no peer certificates")
|
|
}
|
|
|
|
cert := state.PeerCertificates[0]
|
|
intermediates := x509.NewCertPool()
|
|
for _, intermediate := range state.PeerCertificates[1:] {
|
|
intermediates.AddCert(intermediate)
|
|
}
|
|
|
|
if _, err := cert.Verify(x509.VerifyOptions{
|
|
DNSName: parsed.Hostname(),
|
|
Roots: pool,
|
|
Intermediates: intermediates,
|
|
}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "verify certificate")
|
|
}
|
|
|
|
return nil
|
|
},
|
|
},
|
|
}
|
|
conn, err := dialer.DialContext(ctx, "tcp", getHostPort(parsed))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "dial for validate")
|
|
}
|
|
conn.Close()
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateClientAuthTLSConnection validates that a TLS connection can be made
|
|
// to the server identified by the target URL (only the host portion is used)
|
|
// by authenticating the client using the provided certificate. The ctx may
|
|
// specify a timeout or deadline for the TLS connection check.
|
|
func ValidateClientAuthTLSConnection(ctx context.Context, cert *tls.Certificate, targetURL string) error {
|
|
parsed, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "parse url")
|
|
}
|
|
|
|
dialer := &tls.Dialer{
|
|
Config: &tls.Config{
|
|
GetClientCertificate: func(reqInfo *tls.CertificateRequestInfo) (*tls.Certificate, error) {
|
|
return cert, nil
|
|
},
|
|
ServerName: parsed.Hostname(),
|
|
ClientSessionCache: tls.NewLRUClientSessionCache(-1),
|
|
},
|
|
}
|
|
|
|
conn, err := dialer.DialContext(ctx, "tcp", getHostPort(parsed))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "TLS dial")
|
|
}
|
|
defer conn.Close()
|
|
|
|
if _, err = conn.Read(make([]byte, 1024)); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "read from TLS connection")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func getHostPort(u *url.URL) string {
|
|
host, port := u.Hostname(), u.Port()
|
|
if port == "" {
|
|
// the dialer accepts a port number or a service name, so using the scheme
|
|
// as port results in the default port for that service (e.g. 443 for
|
|
// https).
|
|
return net.JoinHostPort(host, u.Scheme)
|
|
}
|
|
return net.JoinHostPort(host, port)
|
|
}
|
|
|
|
// Certificate holds a loaded TLS certificate and its raw parts.
|
|
type Certificate struct {
|
|
Crt tls.Certificate
|
|
RawCrt []byte
|
|
RawKey []byte
|
|
}
|
|
|
|
// LoadClientCertificateFromFiles loads a TLS client certificate from PEM cert and key file paths.
|
|
//
|
|
// Returns (nil, nil) if both files do not exist.
|
|
func LoadClientCertificateFromFiles(crtPath, keyPath string) (*Certificate, error) {
|
|
checkFileExists := func(filePath string) (bool, error) {
|
|
switch s, err := os.Stat(filePath); {
|
|
case err == nil:
|
|
return !s.IsDir(), nil
|
|
case errors.Is(err, os.ErrNotExist):
|
|
return false, nil
|
|
default:
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
if (crtPath != "") != (keyPath != "") {
|
|
return nil, fmt.Errorf(
|
|
"both crt path and key path must be set: crt=%t, key=%t", crtPath != "", keyPath != "",
|
|
)
|
|
}
|
|
if crtPath == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
crtExists, err := checkFileExists(crtPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keyExists, err := checkFileExists(keyPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if crtExists != keyExists {
|
|
return nil, fmt.Errorf(
|
|
"both crt and key files must exist: %s: %t, %s: %t",
|
|
crtPath, crtExists, keyPath, keyExists,
|
|
)
|
|
}
|
|
if !crtExists {
|
|
return nil, nil
|
|
}
|
|
|
|
crtBytes, err := os.ReadFile(crtPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keyBytes, err := os.ReadFile(keyPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
crt, err := parseFullClientCertificate(crtBytes, keyBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Certificate{
|
|
Crt: crt,
|
|
RawCrt: crtBytes,
|
|
RawKey: keyBytes,
|
|
}, nil
|
|
}
|
|
|
|
// LoadClientCertificate loads a client certificate from the given PEM cert and key strings.
|
|
//
|
|
// Returns (nil, nil) if both values are empty.
|
|
func LoadClientCertificate(crt, key string) (*tls.Certificate, error) {
|
|
if (crt != "") != (key != "") {
|
|
return nil, fmt.Errorf(
|
|
"both crt and key must be set: crt=%t, key=%t", crt != "", key != "",
|
|
)
|
|
}
|
|
if crt == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
cert, err := parseFullClientCertificate([]byte(crt), []byte(key))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &cert, nil
|
|
}
|
|
|
|
func parseFullClientCertificate(crt, key []byte) (tls.Certificate, error) {
|
|
cert, err := tls.X509KeyPair(crt, key)
|
|
if err != nil {
|
|
return tls.Certificate{}, err
|
|
}
|
|
// tls.X509KeyPair does not store the parsed certificate leaf.
|
|
// To reduce per-handshake processing, we parse it here.
|
|
//
|
|
// From Adam Langley:
|
|
// The Leaf member is only needed for clients doing client-authentication.
|
|
// This is rare compared to the common case of loading certificates for serving.
|
|
// In the latter case, the parsed form isn't needed because the server just sends
|
|
// the blob to the client and doesn't generally care what's in it.
|
|
parsedLeaf, err := x509.ParseCertificate(cert.Certificate[0])
|
|
if err != nil {
|
|
return tls.Certificate{}, fmt.Errorf("parse leaf certificate: %w", err)
|
|
}
|
|
cert.Leaf = parsedLeaf
|
|
return cert, nil
|
|
}
|
|
|
|
// EncodeCertPEM returns PEM-endcoded certificate data.
|
|
func EncodeCertPEM(cert *x509.Certificate) []byte {
|
|
block := pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: cert.Raw,
|
|
}
|
|
return pem.EncodeToMemory(&block)
|
|
}
|
|
|
|
// EncodePrivateKeyPEM returns PEM-encoded private key data
|
|
func EncodePrivateKeyPEM(key *rsa.PrivateKey) []byte {
|
|
block := pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
|
}
|
|
return pem.EncodeToMemory(&block)
|
|
}
|