diff --git a/lib/client/ca_export.go b/lib/client/ca_export.go index 011be4c4682d9..ad5de7e995e34 100644 --- a/lib/client/ca_export.go +++ b/lib/client/ca_export.go @@ -23,7 +23,6 @@ import ( "encoding/pem" "errors" "fmt" - "log/slog" "strings" "time" @@ -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 @@ -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) - 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. @@ -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 + // 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) @@ -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 } } @@ -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 } diff --git a/lib/client/ca_export_test.go b/lib/client/ca_export_test.go index 2bec5410e195c..e42f0ef0da708 100644 --- a/lib/client/ca_export_test.go +++ b/lib/client/ca_export_test.go @@ -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 { @@ -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 { @@ -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, @@ -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) + } + }) + } +} diff --git a/tool/tctl/common/auth_command.go b/tool/tctl/common/auth_command.go index 300b0d61be55b..36ba3048471f0 100644 --- a/tool/tctl/common/auth_command.go +++ b/tool/tctl/common/auth_command.go @@ -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) }