Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 53 additions & 38 deletions lib/client/ca_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
"encoding/pem"
"errors"
"fmt"
"log/slog"
"strings"
"time"

Expand Down Expand Up @@ -52,23 +51,6 @@ type ExportAuthoritiesRequest struct {
AuthType string
ExportAuthorityFingerprint string
UseCompatVersion bool
Integration string
}

func (r *ExportAuthoritiesRequest) shouldExportIntegration(ctx context.Context) (bool, error) {
switch r.AuthType {
case "github":
if r.Integration == "" {
return false, trace.BadParameter("integration name must be provided for %q CAs", r.AuthType)
}
return true, nil
default:
if r.Integration != "" {
r.Integration = ""
slog.DebugContext(ctx, "Integration name is ignored for non-integration CAs")
}
return false, nil
}
}

// ExportedAuthority represents an exported authority certificate, as returned
Expand Down Expand Up @@ -130,22 +112,9 @@ func exportAllAuthorities(
req ExportAuthoritiesRequest,
exportSecrets bool,
) ([]*ExportedAuthority, error) {
var authorities []*ExportedAuthority
switch isIntegration, err := req.shouldExportIntegration(ctx); {
case err != nil:
authorities, err := exportAuth(ctx, client, req, exportSecrets)
if err != nil {
return nil, trace.Wrap(err)
case isIntegration && exportSecrets:
return nil, trace.NotImplemented("export with secrets is not supported for %q CAs", req.AuthType)
case isIntegration:
authorities, err = exportAuthForIntegration(ctx, client, req)
Comment thread
codingllama marked this conversation as resolved.
if err != nil {
return nil, trace.Wrap(err)
}
default:
authorities, err = exportAuth(ctx, client, req, exportSecrets)
if err != nil {
return nil, trace.Wrap(err)
}
}

// Sanity check that we have at least one authority.
Expand Down Expand Up @@ -423,9 +392,52 @@ func hostCAFormat(ca types.CertAuthority, keyBytes []byte, client authclient.Cli
})
}

func exportAuthForIntegration(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest) ([]*ExportedAuthority, error) {
// IsIntegrationAuthorityType returns true if provided type is an integration CA
// type.
func IsIntegrationAuthorityType(authType string) bool {
return authType == types.IntegrationSubKindGitHub
}

// ExportIntegrationAuthoritiesRequest has the required fields to create an
// export authorities request for integrations.
type ExportIntegrationAuthoritiesRequest struct {
// AuthType is the type of CA to be exported. See
// ExportIntegrationAuthorities for details.
AuthType string
// MatchFingerprint filters authorities using provided fingerprint if
Comment thread
greedy52 marked this conversation as resolved.
// specified. Fingerprint must be the SHA256 of the Authority's public key.
MatchFingerprint string
// Integration is the name of the integration resource.
Integration string
}

// ExportIntegrationAuthorities exports the public keys of all authorities
// associated with an integration.
//
// Integrations that require certificate authorities have their CAs saved as
// plugin credentials per integration. This ensures compatibility with services
// like GitHub which mandate the use of unique CAs cross all integrations.
// In addition, unlike cluster-level CAs, integration CAs are not used between
// Teleport clients/agents/clusters. Integration CAs should only be used by an
// agent to authenticate the service associated with the integration.
//
// Exporting integration CAs requires READ access to the integration. Currently,
// "github" is the only supported AuthType.
//
// "github" AuthType returns the public key of each SSH certificate authority in
// a single line. Each line starts with key type like "ssh-rsa AA..." and can be
// copied to the text box when configuring new CA for a GitHub organization.
// Once a CA is added to the GitHub organization, GitHub only displays the
// SHA256 fingerprint of the key and the date it was added. The MatchFingerprint
// option can be used to verify whether a fingerprint corresponds to that
// particular integration.
func ExportIntegrationAuthorities(ctx context.Context, client authclient.ClientI, req ExportIntegrationAuthoritiesRequest) ([]*ExportedAuthority, error) {
if req.Integration == "" {
return nil, trace.BadParameter("integration name is required when exporting integration authorities")
}

switch req.AuthType {
case "github":
case types.IntegrationSubKindGitHub:
keySet, err := fetchIntegrationCAKeySet(ctx, client, req.Integration)
if err != nil {
return nil, trace.Wrap(err)
Expand Down Expand Up @@ -453,13 +465,13 @@ func fetchIntegrationCAKeySet(ctx context.Context, client authclient.ClientI, in
return resp.CertAuthorities, nil
}

func exportGitHubCAs(keySet *types.CAKeySet, req ExportAuthoritiesRequest) (string, error) {
func exportGitHubCAs(keySet *types.CAKeySet, req ExportIntegrationAuthoritiesRequest) (string, error) {
ret := strings.Builder{}
for _, key := range keySet.SSH {
if req.ExportAuthorityFingerprint != "" {
if req.MatchFingerprint != "" {
if fingerprint, err := sshutils.AuthorizedKeyFingerprint(key.PublicKey); err != nil {
return "", trace.Wrap(err)
} else if !sshutils.EqualFingerprints(req.ExportAuthorityFingerprint, fingerprint) {
} else if !sshutils.EqualFingerprints(req.MatchFingerprint, fingerprint) {
continue
}
}
Expand All @@ -468,5 +480,8 @@ func exportGitHubCAs(keySet *types.CAKeySet, req ExportAuthoritiesRequest) (stri
// cert-authority for easier copy-and-paste.
ret.WriteString(fmt.Sprintf("%s integration=%s\n", strings.TrimSpace(string(key.PublicKey)), req.Integration))
}
if req.MatchFingerprint != "" && ret.Len() == 0 {
return "", trace.NotFound("no authorities found matching the provided fingerprint")
}
return ret.String(), nil
}
119 changes: 91 additions & 28 deletions lib/client/ca_export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/cryptosuites"
"github.com/gravitational/teleport/lib/fixtures"
"github.com/gravitational/teleport/lib/sshutils"
)

type mockAuthClient struct {
Expand Down Expand Up @@ -126,19 +127,8 @@ func TestExportAuthorities(t *testing.T) {
require.NotNil(t, privKey, "x509.ParsePKCS8PrivateKey returned a nil key")
}

validateGitHubCAFunc := func(t *testing.T, s string) {
require.Contains(t, s, fixtures.SSHCAPublicKey)
}

mockedAuthClient := &mockAuthClient{
server: testAuth.AuthServer,
integrationsClient: mockIntegrationsClient{
caKeySet: &types.CAKeySet{
SSH: []*types.SSHKeyPair{{
PublicKey: []byte(fixtures.SSHCAPublicKey),
}},
},
},
}

for _, tt := range []struct {
Expand Down Expand Up @@ -288,23 +278,6 @@ func TestExportAuthorities(t *testing.T) {
assertNoSecrets: validateTLSCertificateDERFunc,
assertSecrets: validateRSAPrivateKeyDERFunc,
},
{
name: "github missing integration",
req: ExportAuthoritiesRequest{
AuthType: "github",
},
errorCheck: require.Error,
},
{
name: "github",
req: ExportAuthoritiesRequest{
AuthType: "github",
Integration: "my-github",
},
errorCheck: require.NoError,
assertNoSecrets: validateGitHubCAFunc,
skipSecrets: true, // not supported for GitHub
},
} {
runTest := func(
t *testing.T,
Expand Down Expand Up @@ -528,3 +501,93 @@ func (m *multiCAAuthClient) PerformMFACeremony(
// Skip MFA ceremonies.
return nil, &mfa.ErrMFANotRequired
}

func TestExportIntegrationAuthorities(t *testing.T) {
t.Parallel()

ctx := context.Background()
testAuth, err := auth.NewTestAuthServer(auth.TestAuthServerConfig{
ClusterName: "localcluster",
Dir: t.TempDir(),
})
require.NoError(t, err)

fingerprint, err := sshutils.AuthorizedKeyFingerprint([]byte(fixtures.SSHCAPublicKey))
require.NoError(t, err)

mockedAuthClient := &mockAuthClient{
server: testAuth.AuthServer,
integrationsClient: mockIntegrationsClient{
caKeySet: &types.CAKeySet{
SSH: []*types.SSHKeyPair{{
PublicKey: []byte(fixtures.SSHCAPublicKey),
}},
},
},
}

for _, tc := range []struct {
name string
req ExportIntegrationAuthoritiesRequest
checkError require.ErrorAssertionFunc
checkOutput func(*testing.T, []*ExportedAuthority)
}{
{
name: "missing integration",
req: ExportIntegrationAuthoritiesRequest{
AuthType: "github",
},
checkError: require.Error,
},
{
name: "unknown type",
req: ExportIntegrationAuthoritiesRequest{
AuthType: "unknown",
Integration: "integration",
},
checkError: require.Error,
},
{
name: "github",
req: ExportIntegrationAuthoritiesRequest{
AuthType: "github",
Integration: "integration",
},
checkError: require.NoError,
checkOutput: func(t *testing.T, authorities []*ExportedAuthority) {
require.Len(t, authorities, 1)
require.Contains(t, string(authorities[0].Data), fixtures.SSHCAPublicKey)
},
},
{
name: "matching fingerprint",
req: ExportIntegrationAuthoritiesRequest{
AuthType: "github",
Integration: "integration",
MatchFingerprint: fingerprint,
},
checkError: require.NoError,
checkOutput: func(t *testing.T, authorities []*ExportedAuthority) {
require.Len(t, authorities, 1)
require.Contains(t, string(authorities[0].Data), fixtures.SSHCAPublicKey)
},
},
{
name: "no matching fingerprint",
req: ExportIntegrationAuthoritiesRequest{
AuthType: "github",
Integration: "integration",
MatchFingerprint: "something-does-not-match",
},
checkError: require.Error,
},
} {
t.Run(tc.name, func(t *testing.T) {
authorities, err := ExportIntegrationAuthorities(ctx, mockedAuthClient, tc.req)
tc.checkError(t, err)
if tc.checkOutput != nil {
tc.checkOutput(t, authorities)
}
})
}
}
43 changes: 28 additions & 15 deletions tool/tctl/common/auth_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,25 +232,38 @@ var allowedCRLCertificateTypes = []string{
string(types.UserCA),
}

func (a *AuthCommand) exportAuthorities(ctx context.Context, clt authCommandClient) ([]*client.ExportedAuthority, error) {
switch {
case client.IsIntegrationAuthorityType(a.authType):
if a.exportPrivateKeys {
return nil, trace.BadParameter("exporting private keys is not supported for integration authorities")
}
return client.ExportIntegrationAuthorities(ctx, clt, client.ExportIntegrationAuthoritiesRequest{
AuthType: a.authType,
MatchFingerprint: a.exportAuthorityFingerprint,
Integration: a.integration,
})

case a.exportPrivateKeys:
return client.ExportAllAuthoritiesSecrets(ctx, clt, client.ExportAuthoritiesRequest{
AuthType: a.authType,
ExportAuthorityFingerprint: a.exportAuthorityFingerprint,
UseCompatVersion: a.compatVersion == "1.0",
})
default:
return client.ExportAllAuthorities(ctx, clt, client.ExportAuthoritiesRequest{
AuthType: a.authType,
ExportAuthorityFingerprint: a.exportAuthorityFingerprint,
UseCompatVersion: a.compatVersion == "1.0",
})
}
}

// ExportAuthorities outputs the list of authorities in OpenSSH compatible formats
// If --type flag is given, only prints keys for CAs of this type, otherwise
// prints all keys
func (a *AuthCommand) ExportAuthorities(ctx context.Context, clt authCommandClient) error {
exportFunc := client.ExportAllAuthorities
if a.exportPrivateKeys {
exportFunc = client.ExportAllAuthoritiesSecrets
}

authorities, err := exportFunc(
ctx,
clt,
client.ExportAuthoritiesRequest{
AuthType: a.authType,
ExportAuthorityFingerprint: a.exportAuthorityFingerprint,
UseCompatVersion: a.compatVersion == "1.0",
Integration: a.integration,
},
)
authorities, err := a.exportAuthorities(ctx, clt)
if err != nil {
return trace.Wrap(err)
}
Expand Down