Apply overrides to Windows certificates (#66411)

* Apply overrides to Windows certificates

* Add lib/subca.CalculateOverrideResult.ToClientOverrideDetailsProto

* Propagate CA override details to audit
This commit is contained in:
Alan Parra
2026-05-11 17:03:50 +00:00
committed by GitHub
parent 02c315e31e
commit 86efcb30c2
12 changed files with 249 additions and 20 deletions
+6
View File
@@ -151,6 +151,7 @@ import (
dbvnet "github.com/gravitational/teleport/lib/srv/db/vnet"
"github.com/gravitational/teleport/lib/sshca"
"github.com/gravitational/teleport/lib/sshutils"
"github.com/gravitational/teleport/lib/subca"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/tpm"
usagereporter "github.com/gravitational/teleport/lib/usagereporter/teleport"
@@ -788,6 +789,7 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (as *Server, err error) {
insecureMode: cfg.InsecureMode,
remoteClusterRefreshLimit: cmp.Or(cfg.RemoteClusterRefreshLimit, defaultRemoteClusterRefreshLimit),
remoteClusterRefreshBuckets: cmp.Or(cfg.RemoteClusterRefreshBuckets, defaultRemoteClusterRefreshBuckets),
subCAEnabled: subca.Enabled(),
}
as.inventory = inventory.NewController(as, services,
inventory.WithAuthServerID(cfg.HostUUID),
@@ -1558,6 +1560,10 @@ type Server struct {
// RemoteClusterRefreshBuckets is the maximum number of refresh cycles that should guarantee the status update
// of all remote clusters if their number exceeds RemoteClusterRefreshLimit × RemoteClusterRefreshBuckets.
remoteClusterRefreshBuckets int
// subCAEnabled records the value of subca.Enabled()
// Used by tests to enable the feature.
subCAEnabled bool
}
// SetSAMLService registers svc as the SAMLService that provides the SAML
+18 -1
View File
@@ -37,6 +37,7 @@ import (
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/entitlements"
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/subca"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/winpki"
@@ -88,6 +89,21 @@ func (a *Server) GenerateWindowsDesktopCert(ctx context.Context, req *proto.Wind
if err != nil {
return nil, trace.Wrap(err)
}
// Calculate CA override.
subCAResolver, err := subca.NewCAOverrideResolver(a.Cache, a.subCAEnabled)
if err != nil {
return nil, trace.Wrap(err)
}
overrideResult, err := subCAResolver.CalculateOverride(ctx, types.CertAuthorityOverrideID{
ClusterName: ca.GetClusterName(),
CAType: string(ca.GetType()),
}, subca.Certificate{PEM: caCert})
if err != nil {
return nil, trace.Wrap(err, "calculate CA override")
}
caCert = overrideResult.CACertificate.PEM
tlsCA, err := tlsca.FromCertAndSigner(caCert, signer)
if err != nil {
return nil, trace.Wrap(err)
@@ -136,7 +152,8 @@ func (a *Server) GenerateWindowsDesktopCert(ctx context.Context, req *proto.Wind
return nil, trace.Wrap(err)
}
return &proto.WindowsDesktopCertResponse{
Cert: cert,
Cert: cert,
CaOverride: overrideResult.ToClientOverrideDetailsProto(),
}, nil
}
+82
View File
@@ -19,13 +19,22 @@
package auth_test
import (
"crypto/x509"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/testing/protocmp"
"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/utils/tlsutils"
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/modules/modulestest"
"github.com/gravitational/teleport/lib/subca/testenv"
"github.com/gravitational/teleport/lib/winpki"
)
// TestDesktopAccessDisabled makes sure desktop access can be disabled via modules.
@@ -49,3 +58,76 @@ func TestDesktopAccessDisabled(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "this Teleport cluster is not licensed for desktop access, please contact the cluster administrator")
}
func TestDesktopAccessCAOverrides(t *testing.T) {
t.Parallel()
tlsServer := newTestTLSServer(t)
authServer := tlsServer.Auth()
authServer.SetSubCAEnabled(true)
cn, err := authServer.GetClusterName(t.Context())
require.NoError(t, err)
clusterName := cn.GetClusterName()
// Fetch the CA and self-signed certificate.
const loadKeys = false
ca, err := authServer.GetCertAuthority(t.Context(), types.CertAuthID{
Type: types.WindowsCA,
DomainName: clusterName,
}, loadKeys)
require.NoError(t, err)
caCert, err := tlsutils.ParseCertificatePEM(ca.GetActiveKeys().TLS[0].Cert)
require.NoError(t, err)
runTest := func(
t *testing.T,
wantParent *x509.Certificate,
wantDetails *proto.CAOverrideCertificateDetails,
) {
genResp, err := winpki.GenerateWindowsDesktopCredentials(t.Context(), authServer, &winpki.GenerateCredentialsRequest{
Username: "Administrator",
Domain: "LLAMA",
TTL: 1 * time.Hour,
ClusterName: clusterName,
})
require.NoError(t, err, "GenerateWindowsDesktopCredentials errored")
genCert, err := x509.ParseCertificate(genResp.CertDER)
require.NoError(t, err)
assert.NoError(t,
genCert.CheckSignatureFrom(wantParent),
"Certificate signature verification failed (self-signed CA)",
)
if diff := cmp.Diff(wantDetails, genResp.CAOverrideDetails, protocmp.Transform()); diff != "" {
t.Errorf("genResp.CAOverrideDetails mismatch (-want +got)\n%s", diff)
}
}
t.Run("generate without override", func(t *testing.T) {
runTest(t, caCert, nil)
})
// Create an active override for caCert.
externalCA, err := testenv.NewSelfSignedCA(nil)
require.NoError(t, err)
env := &testenv.Env{
Clock: tlsServer.Clock(),
ClusterName: clusterName,
ExternalRoot: externalCA,
}
caOverride := env.NewOverrideForCA(t, ca, nil)
override := caOverride.Spec.CertificateOverrides[0]
override.Disabled = false // enable
_, err = authServer.CreateCertAuthorityOverride(t.Context(), caOverride)
require.NoError(t, err, "CreateCertAuthorityOverride errored")
overrideCert, err := tlsutils.ParseCertificatePEM([]byte(override.Certificate))
require.NoError(t, err)
t.Run("generate with override", func(t *testing.T) {
runTest(t, overrideCert, &proto.CAOverrideCertificateDetails{
PublicKeyHash: override.PublicKey,
})
})
}
+4
View File
@@ -207,6 +207,10 @@ func (a *Server) CreateGithubUser(ctx context.Context, p *CreateUserParams, dryR
return a.createGithubUser(ctx, p, dryRun)
}
func (a *Server) SetSubCAEnabled(b bool) {
a.subCAEnabled = b
}
func BuildAPIEndpoint(apiEndpointURLStr string) (string, error) {
return buildAPIEndpoint(apiEndpointURLStr)
}
+3
View File
@@ -52,6 +52,8 @@ type desktopSessionAuditor struct {
compactor auditCompactor
auditCache sharedDirectoryAuditCache
caOverrideDetails *events.CAOverrideCertificateDetails
}
func (d *desktopSessionAuditor) getSessionMetadata() events.SessionMetadata {
@@ -115,6 +117,7 @@ func (d *desktopSessionAuditor) makeSessionStart(err error) *events.WindowsDeskt
WindowsUser: d.windowsUser,
DesktopLabels: d.desktop.GetAllLabels(),
NLA: d.enableNLA && !d.desktop.NonAD(),
CAOverride: d.caOverrideDetails,
}
if err != nil {
+18
View File
@@ -28,6 +28,7 @@ import (
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/testing/protocmp"
tdpbv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/desktop/v1"
"github.com/gravitational/teleport/api/types"
@@ -171,6 +172,23 @@ func TestSessionStartEvent(t *testing.T) {
require.Empty(t, cmp.Diff(test.exp(), *startEvent))
})
}
t.Run("audit emits CAOverride details", func(t *testing.T) {
t.Parallel()
_, audit := setup(testDesktop)
const aPublicKeyHash = "6fbd7ba3f34c526f5d6d8ea2659f9fb5ca031712ee588ce35941d568742d44ed"
audit.caOverrideDetails = &events.CAOverrideCertificateDetails{
Active: true,
PublicKeyHash: aPublicKeyHash,
}
got := audit.makeSessionStart(nil)
if diff := cmp.Diff(audit.caOverrideDetails, got.CAOverride, protocmp.Transform()); diff != "" {
t.Errorf("CAOverrideDetails mismatch (-want +got)\n%s", diff)
}
})
}
func TestSessionEndEvent(t *testing.T) {
+31 -9
View File
@@ -487,7 +487,7 @@ func (s *WindowsService) issueNewTLSConfigForLDAP() (*tls.Config, error) {
if s.cfg.SID == "" {
s.cfg.Logger.WarnContext(context.Background(), "LDAP configuration is missing service account SID")
}
certDER, keyDER, err := s.generateCredentials(s.closeCtx, generateCredentialsRequest{
genResp, err := s.generateCredentials(s.closeCtx, generateCredentialsRequest{
username: user,
domain: s.cfg.Domain,
ttl: windowsDesktopServiceCertTTL,
@@ -498,12 +498,12 @@ func (s *WindowsService) issueNewTLSConfigForLDAP() (*tls.Config, error) {
return nil, trace.Wrap(err)
}
cert, err := x509.ParseCertificate(certDER)
cert, err := x509.ParseCertificate(genResp.CertDER)
if err != nil {
return nil, trace.Wrap(err, "parsing cert DER")
}
key, err := x509.ParsePKCS1PrivateKey(keyDER)
key, err := x509.ParsePKCS1PrivateKey(genResp.KeyDER)
if err != nil {
return nil, trace.Wrap(err, "parsing key DER")
}
@@ -944,10 +944,20 @@ func (s *WindowsService) connectRDP(ctx context.Context, log *slog.Logger, tdpCo
}
// Generate client certificates to be used for the RDP connection.
certDER, keyDER, err := s.generateUserCert(ctx, windowsUser, windowsUserCertTTL, desktop, createUsers, groups)
genResp, err := s.generateUserCert(ctx, windowsUser, windowsUserCertTTL, desktop, createUsers, groups)
if err != nil {
return trace.Wrap(err, "could not generate client certificates for RDP")
}
certDER := genResp.CertDER
keyDER := genResp.KeyDER
// Include CA override data in audit.
if genResp.CAOverrideDetails != nil {
audit.caOverrideDetails = &events.CAOverrideCertificateDetails{
Active: true,
PublicKeyHash: genResp.CAOverrideDetails.PublicKeyHash,
}
}
if err := s.trackSession(ctx, &identity, windowsUser, string(sessionID), desktop); err != nil {
return trace.Wrap(err)
@@ -1280,7 +1290,14 @@ type entry struct {
// generateUserCert generates a keypair for the given Windows username,
// optionally querying LDAP for the user's Security Identifier.
func (s *WindowsService) generateUserCert(ctx context.Context, username string, ttl time.Duration, desktop types.WindowsDesktop, createUsers bool, groups []string) (certDER, keyDER []byte, err error) {
func (s *WindowsService) generateUserCert(
ctx context.Context,
username string,
ttl time.Duration,
desktop types.WindowsDesktop,
createUsers bool,
groups []string,
) (*winpki.GenerateCredentialsResponse, error) {
var activeDirectorySID string
var distinguishedName string
@@ -1314,12 +1331,12 @@ func (s *WindowsService) generateUserCert(ctx context.Context, username string,
}, nil
})
if err != nil {
return nil, nil, trace.Wrap(err)
return nil, trace.Wrap(err)
}
activeDirectorySID = entry.sid
distinguishedName = entry.distinguishedName
}
return s.generateCredentials(ctx, generateCredentialsRequest{
genResp, err := s.generateCredentials(ctx, generateCredentialsRequest{
username: username,
domain: desktop.GetDomain(),
distinguishedName: distinguishedName,
@@ -1329,6 +1346,7 @@ func (s *WindowsService) generateUserCert(ctx context.Context, username string,
createUser: createUsers,
groups: groups,
})
return genResp, trace.Wrap(err)
}
// generateCredentialsRequest are the request parameters for generating a windows cert/key pair
@@ -1359,8 +1377,11 @@ type generateCredentialsRequest struct {
// the regular Teleport user certificate, to meet the requirements of Active
// Directory. See:
// https://docs.microsoft.com/en-us/windows/security/identity-protection/smart-cards/smart-card-certificate-requirements-and-enumeration
func (s *WindowsService) generateCredentials(ctx context.Context, request generateCredentialsRequest) (certDER, keyDER []byte, err error) {
return winpki.GenerateWindowsDesktopCredentials(ctx, s.cfg.AuthClient, &winpki.GenerateCredentialsRequest{
func (s *WindowsService) generateCredentials(
ctx context.Context,
request generateCredentialsRequest,
) (*winpki.GenerateCredentialsResponse, error) {
resp, err := winpki.GenerateWindowsDesktopCredentials(ctx, s.cfg.AuthClient, &winpki.GenerateCredentialsRequest{
Username: request.username,
DistinguishedName: request.distinguishedName,
Domain: request.domain,
@@ -1373,6 +1394,7 @@ func (s *WindowsService) generateCredentials(ctx context.Context, request genera
Groups: request.groups,
OmitCDP: request.omitCDP,
})
return resp, trace.Wrap(err)
}
// trackSession creates a session tracker for the given sessionID and
+3 -1
View File
@@ -210,7 +210,7 @@ func TestGenerateCredentials(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
certb, keyb, err := winpki.GenerateWindowsDesktopCredentials(ctx, client, &winpki.GenerateCredentialsRequest{
genResp, err := winpki.GenerateWindowsDesktopCredentials(ctx, client, &winpki.GenerateCredentialsRequest{
AD: true,
Username: user,
Domain: domain,
@@ -219,6 +219,8 @@ func TestGenerateCredentials(t *testing.T) {
ActiveDirectorySID: test.activeDirectorySID,
DisableWindowsCASupportForTesting: test.disableWindowsCASupport,
})
certb := genResp.CertDER
keyb := genResp.KeyDER
require.NoError(t, err)
require.NotNil(t, certb)
require.NotNil(t, keyb)
+14
View File
@@ -22,6 +22,7 @@ import (
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/client/proto"
subcav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/subca/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/utils/tlsutils"
@@ -77,6 +78,19 @@ type CalculateOverrideResult struct {
CAChain []Certificate
}
// ToClientOverrideDetailsProto returns a [proto.CAOverrideCertificateDetails]
// instance matching the CalculateOverrideResult.
//
// Returns nil if OverrideActive is false.
func (res *CalculateOverrideResult) ToClientOverrideDetailsProto() *proto.CAOverrideCertificateDetails {
if res == nil || !res.OverrideActive {
return nil
}
return &proto.CAOverrideCertificateDetails{
PublicKeyHash: res.PublicKeyHash,
}
}
// CalculateOverride calculates CA overrides for a self-signed CA certificate.
//
// Returns a result with the CA certificate and chain to be used, with either
+47
View File
@@ -23,7 +23,9 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/testing/protocmp"
"github.com/gravitational/teleport/api/client/proto"
headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
subcav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/subca/v1"
"github.com/gravitational/teleport/api/types"
@@ -33,6 +35,51 @@ import (
"github.com/gravitational/teleport/lib/tlscatest"
)
func TestCalculateOverrideResult_ToClientOverrideDetailsProto(t *testing.T) {
t.Parallel()
const aPublicKeyHash = "6fbd7ba3f34c526f5d6d8ea2659f9fb5ca031712ee588ce35941d568742d44ed"
tests := []struct {
name string
res *subca.CalculateOverrideResult
want *proto.CAOverrideCertificateDetails
}{
{
name: "nil returns nil",
},
{
name: "not active returns nil",
res: &subca.CalculateOverrideResult{
OverrideActive: false,
PublicKeyHash: aPublicKeyHash,
CACertificate: subca.Certificate{PEM: []byte("llama456")},
},
},
{
name: "active returns proto",
res: &subca.CalculateOverrideResult{
OverrideActive: true,
PublicKeyHash: aPublicKeyHash,
CACertificate: subca.Certificate{PEM: []byte("llama456")},
},
want: &proto.CAOverrideCertificateDetails{
PublicKeyHash: aPublicKeyHash,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got := test.res.ToClientOverrideDetailsProto()
if diff := cmp.Diff(test.want, got, protocmp.Transform()); diff != "" {
t.Errorf("ToOverrideDetailsProto mismatch (-want +got)\n%s", diff)
}
})
}
}
func TestCAOverrideResolver_CalculateOverride(t *testing.T) {
t.Parallel()
+21 -7
View File
@@ -286,15 +286,27 @@ type GenerateCredentialsRequest struct {
DisableWindowsCASupportForTesting bool
}
// GenerateCredentialsResponse is the outcome of a successful
// [GenerateWindowsDesktopCredentials] invocation.
type GenerateCredentialsResponse struct {
CertDER []byte
KeyDER []byte
CAOverrideDetails *proto.CAOverrideCertificateDetails
}
// GenerateWindowsDesktopCredentials generates a private key / certificate pair for the given
// Windows username. The certificate has certain special fields different from
// the regular Teleport user certificate, to meet the requirements of Active
// Directory. See:
// https://docs.microsoft.com/en-us/windows/security/identity-protection/smart-cards/smart-card-certificate-requirements-and-enumeration
func GenerateWindowsDesktopCredentials(ctx context.Context, auth AuthInterface, req *GenerateCredentialsRequest) (certDER, keyDER []byte, err error) {
func GenerateWindowsDesktopCredentials(
ctx context.Context,
auth AuthInterface,
req *GenerateCredentialsRequest,
) (*GenerateCredentialsResponse, error) {
certReq, err := getCertRequest(req)
if err != nil {
return nil, nil, trace.Wrap(err)
return nil, trace.Wrap(err)
}
genResp, err := auth.GenerateWindowsDesktopCert(ctx, &proto.WindowsDesktopCertRequest{
CSR: certReq.csrPEM,
@@ -303,15 +315,17 @@ func GenerateWindowsDesktopCredentials(ctx context.Context, auth AuthInterface,
SupportsWindowsCA: !req.DisableWindowsCASupportForTesting,
})
if err != nil {
return nil, nil, trace.Wrap(err)
return nil, trace.Wrap(err)
}
certBlock, _ := pem.Decode(genResp.Cert)
if certBlock == nil {
return nil, nil, trace.BadParameter("failed to decode certificate")
return nil, trace.BadParameter("failed to decode certificate")
}
certDER = certBlock.Bytes
keyDER = certReq.keyDER
return certDER, keyDER, nil
return &GenerateCredentialsResponse{
CertDER: certBlock.Bytes,
KeyDER: certReq.keyDER,
CAOverrideDetails: genResp.CaOverride,
}, nil
}
// generateDatabaseCredentials generates a private key / certificate pair for the given
+2 -2
View File
@@ -430,7 +430,7 @@ func (a *AuthCommand) generateWindowsCert(ctx context.Context, clusterAPI certif
return trace.Wrap(err)
}
certDER, _, err := winpki.GenerateWindowsDesktopCredentials(ctx, clusterAPI, &winpki.GenerateCredentialsRequest{
genResp, err := winpki.GenerateWindowsDesktopCredentials(ctx, clusterAPI, &winpki.GenerateCredentialsRequest{
Username: a.windowsUser,
Domain: a.windowsDomain,
PKIDomain: a.windowsPKIDomain,
@@ -445,7 +445,7 @@ func (a *AuthCommand) generateWindowsCert(ctx context.Context, clusterAPI certif
_, err = identityfile.Write(ctx, identityfile.WriteConfig{
OutputPath: a.output,
WindowsDesktopCerts: map[string][]byte{a.windowsUser: certDER},
WindowsDesktopCerts: map[string][]byte{a.windowsUser: genResp.CertDER},
Format: a.outputFormat,
OverwriteDestination: a.signOverwrite,
Writer: a.identityWriter,