diff --git a/lib/client/ca_export.go b/lib/client/ca_export.go index 6a1aefddb058d..4fbd59fc4df98 100644 --- a/lib/client/ca_export.go +++ b/lib/client/ca_export.go @@ -71,9 +71,21 @@ func (r *ExportAuthoritiesRequest) shouldExportIntegration(ctx context.Context) } } -// ExportAuthorities returns the list of authorities in OpenSSH compatible formats as a string. -// If the ExportAuthoritiesRequest.AuthType is present only prints keys for CAs of this type, -// otherwise returns host and user SSH keys. +// ExportedAuthority represents an exported authority certificate, as returned +// by [ExportAllAuthorities] or [ExportAllAuthoritiesSecrets]. +type ExportedAuthority struct { + // Data is the output of the exported authority. + // May be an SSH authorized key, an SSH known hosts entry, a DER or a PEM, + // depending on the type of the exported authority. + Data []byte +} + +// ExportAllAuthorities exports public keys of all authorities of a particular +// type. The export format depends on the authority type, see below for +// details. +// +// An empty ExportAuthoritiesRequest.AuthType is interpreted as an export for +// host and user SSH keys. // // Exporting using "tls*", "database", "windows" AuthType: // Returns the certificate authority public key to be used by systems that rely on TLS. @@ -95,27 +107,92 @@ func (r *ExportAuthoritiesRequest) shouldExportIntegration(ctx context.Context) // For example: // > @cert-authority *.cluster-a ssh-rsa AAA... type=host // URL encoding is used to pass the CA type and allowed logins into the comment field. -func ExportAuthorities(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest) (string, error) { - if isIntegration, err := req.shouldExportIntegration(ctx); err != nil { - return "", trace.Wrap(err) - } else if isIntegration { - return exportAuthForIntegration(ctx, client, req) +// +// At least one authority is guaranteed on success. +func ExportAllAuthorities(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest) ([]*ExportedAuthority, error) { + const exportSecrets = false + return exportAllAuthorities(ctx, client, req, exportSecrets) +} + +// ExportAllAuthoritiesSecrets exports private keys of all authorities of a +// particular type. +// See [ExportAllAuthorities] for more information. +// +// At least one authority is guaranteed on success. +func ExportAllAuthoritiesSecrets(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest) ([]*ExportedAuthority, error) { + const exportSecrets = true + return exportAllAuthorities(ctx, client, req, exportSecrets) +} + +func exportAllAuthorities( + ctx context.Context, + client authclient.ClientI, + req ExportAuthoritiesRequest, + exportSecrets bool, +) ([]*ExportedAuthority, error) { + var authorities []*ExportedAuthority + switch isIntegration, err := req.shouldExportIntegration(ctx); { + case 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) + } } - return exportAuth(ctx, client, req, false /* exportSecrets */) + + // Sanity check that we have at least one authority. + // Not expected to happen in practice. + if len(authorities) == 0 { + return nil, trace.BadParameter("export returned zero authorities") + } + + return authorities, nil } -// ExportAuthoritiesSecrets exports the Authority Certificate secrets (private keys). -// See ExportAuthorities for more information. +// ExportAuthorities is the single-authority version of [ExportAllAuthorities]. +// Soft-deprecated, prefer using [ExportAllAuthorities] and handling exports +// with more than one authority gracefully. +func ExportAuthorities(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest) (string, error) { + // TODO(codingllama): Remove ExportAuthorities. + return exportAuthorities(ctx, client, req, ExportAllAuthorities) +} + +// ExportAuthoritiesSecrets is the single-authority variant of +// [ExportAllAuthoritiesSecrets]. +// Soft-deprecated, prefer using [ExportAllAuthoritiesSecrets] and handling +// exports with more than one authority gracefully. func ExportAuthoritiesSecrets(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest) (string, error) { - if isIntegration, err := req.shouldExportIntegration(ctx); err != nil { + // TODO(codingllama): Remove ExportAuthoritiesSecrets. + return exportAuthorities(ctx, client, req, ExportAllAuthoritiesSecrets) +} + +func exportAuthorities( + ctx context.Context, + client authclient.ClientI, + req ExportAuthoritiesRequest, + exportAllFunc func(context.Context, authclient.ClientI, ExportAuthoritiesRequest) ([]*ExportedAuthority, error), +) (string, error) { + authorities, err := exportAllFunc(ctx, client, req) + if err != nil { return "", trace.Wrap(err) - } else if isIntegration { - return "", trace.NotImplemented("export with secrets is not supported for %q CAs", req.AuthType) } - return exportAuth(ctx, client, req, true /* exportSecrets */) + // At least one authority is guaranteed on success by both ExportAll methods. + if l := len(authorities); l > 1 { + return "", trace.BadParameter("export returned %d authorities, expected exactly one", l) + } + + return string(authorities[0].Data), nil } -func exportAuth(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest, exportSecrets bool) (string, error) { +func exportAuth(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest, exportSecrets bool) ([]*ExportedAuthority, error) { var typesToExport []types.CertAuthType if exportSecrets { @@ -123,7 +200,7 @@ func exportAuth(ctx context.Context, client authclient.ClientI, req ExportAuthor if err == nil { ctx = mfa.ContextWithMFAResponse(ctx, mfaResponse) } else if !errors.Is(err, &mfa.ErrMFANotRequired) && !errors.Is(err, &mfa.ErrMFANotSupported) { - return "", trace.Wrap(err) + return nil, trace.Wrap(err) } } @@ -205,13 +282,13 @@ func exportAuth(ctx context.Context, client authclient.ClientI, req ExportAuthor } else { authType := types.CertAuthType(req.AuthType) if err := authType.Check(); err != nil { - return "", trace.Wrap(err) + return nil, trace.Wrap(err) } typesToExport = []types.CertAuthType{authType} } localAuthName, err := client.GetDomainName(ctx) if err != nil { - return "", trace.Wrap(err) + return nil, trace.Wrap(err) } // fetch authorities via auth API (and only take local CAs, ignoring @@ -220,7 +297,7 @@ func exportAuth(ctx context.Context, client authclient.ClientI, req ExportAuthor for _, at := range typesToExport { cas, err := client.GetCertAuthorities(ctx, at, exportSecrets) if err != nil { - return "", trace.Wrap(err) + return nil, trace.Wrap(err) } for _, ca := range cas { if ca.GetClusterName() == localAuthName { @@ -236,7 +313,7 @@ func exportAuth(ctx context.Context, client authclient.ClientI, req ExportAuthor if req.ExportAuthorityFingerprint != "" { fingerprint, err := sshutils.PrivateKeyFingerprint(key.PrivateKey) if err != nil { - return "", trace.Wrap(err) + return nil, trace.Wrap(err) } if fingerprint != req.ExportAuthorityFingerprint { @@ -254,7 +331,7 @@ func exportAuth(ctx context.Context, client authclient.ClientI, req ExportAuthor if req.ExportAuthorityFingerprint != "" { fingerprint, err := sshutils.AuthorizedKeyFingerprint(key.PublicKey) if err != nil { - return "", trace.Wrap(err) + return nil, trace.Wrap(err) } if fingerprint != req.ExportAuthorityFingerprint { @@ -267,7 +344,7 @@ func exportAuth(ctx context.Context, client authclient.ClientI, req ExportAuthor if req.UseCompatVersion { castr, err := hostCAFormat(ca, key.PublicKey, client) if err != nil { - return "", trace.Wrap(err) + return nil, trace.Wrap(err) } ret.WriteString(castr) @@ -282,10 +359,10 @@ func exportAuth(ctx context.Context, client authclient.ClientI, req ExportAuthor case types.HostCA: castr, err = hostCAFormat(ca, key.PublicKey, client) default: - return "", trace.BadParameter("unknown user type: %q", ca.GetType()) + return nil, trace.BadParameter("unknown user type: %q", ca.GetType()) } if err != nil { - return "", trace.Wrap(err) + return nil, trace.Wrap(err) } // write the export friendly string @@ -293,7 +370,9 @@ func exportAuth(ctx context.Context, client authclient.ClientI, req ExportAuthor } } - return ret.String(), nil + return []*ExportedAuthority{ + {Data: []byte(ret.String())}, + }, nil } type exportTLSAuthorityRequest struct { @@ -302,10 +381,10 @@ type exportTLSAuthorityRequest struct { ExportPrivateKeys bool } -func exportTLSAuthority(ctx context.Context, client authclient.ClientI, req exportTLSAuthorityRequest) (string, error) { +func exportTLSAuthority(ctx context.Context, client authclient.ClientI, req exportTLSAuthorityRequest) ([]*ExportedAuthority, error) { clusterName, err := client.GetDomainName(ctx) if err != nil { - return "", trace.Wrap(err) + return nil, trace.Wrap(err) } certAuthority, err := client.GetCertAuthority( @@ -314,29 +393,33 @@ func exportTLSAuthority(ctx context.Context, client authclient.ClientI, req expo req.ExportPrivateKeys, ) if err != nil { - return "", trace.Wrap(err) + return nil, trace.Wrap(err) } - if l := len(certAuthority.GetActiveKeys().TLS); l != 1 { - return "", trace.BadParameter("expected one TLS key pair, got %v", l) - } - keyPair := certAuthority.GetActiveKeys().TLS[0] + activeKeys := certAuthority.GetActiveKeys().TLS + // TODO(codingllama): Export AdditionalTrustedKeys as well? - bytesToExport := keyPair.Cert - if req.ExportPrivateKeys { - bytesToExport = keyPair.Key - } + authorities := make([]*ExportedAuthority, len(activeKeys)) + for i, activeKey := range activeKeys { + bytesToExport := activeKey.Cert + if req.ExportPrivateKeys { + bytesToExport = activeKey.Key + } - if !req.UnpackPEM { - return string(bytesToExport), nil - } + if req.UnpackPEM { + block, _ := pem.Decode(bytesToExport) + if block == nil { + return nil, trace.BadParameter("invalid PEM data") + } + bytesToExport = block.Bytes + } - b, _ := pem.Decode(bytesToExport) - if b == nil { - return "", trace.BadParameter("invalid PEM data") + authorities[i] = &ExportedAuthority{ + Data: bytesToExport, + } } - return string(b.Bytes), nil + return authorities, nil } // userCAFormat returns the certificate authority public key exported as a single @@ -375,21 +458,23 @@ func hostCAFormat(ca types.CertAuthority, keyBytes []byte, client authclient.Cli }) } -func exportAuthForIntegration(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest) (string, error) { +func exportAuthForIntegration(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest) ([]*ExportedAuthority, error) { switch req.AuthType { case "github": keySet, err := fetchIntegrationCAKeySet(ctx, client, req.Integration) if err != nil { - return "", trace.Wrap(err) + return nil, trace.Wrap(err) } ret, err := exportGitHubCAs(keySet, req) if err != nil { - return "", trace.Wrap(err) + return nil, trace.Wrap(err) } - return ret, nil + return []*ExportedAuthority{ + {Data: []byte(ret)}, + }, nil default: - return "", trace.BadParameter("unknown integration CA type %q", req.AuthType) + return nil, trace.BadParameter("unknown integration CA type %q", req.AuthType) } } diff --git a/lib/client/ca_export_test.go b/lib/client/ca_export_test.go index cf7ff693716fe..5e7004eb88543 100644 --- a/lib/client/ca_export_test.go +++ b/lib/client/ca_export_test.go @@ -20,21 +20,27 @@ package client import ( "context" + "crypto/rand" "crypto/x509" + "crypto/x509/pkix" "encoding/pem" "fmt" + "math/big" "testing" + "time" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" "google.golang.org/grpc" - "github.com/gravitational/teleport/api/client/proto" + clientpb "github.com/gravitational/teleport/api/client/proto" integrationpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1" "github.com/gravitational/teleport/api/mfa" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/utils/keys" "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/cryptosuites" "github.com/gravitational/teleport/lib/fixtures" ) @@ -56,7 +62,7 @@ func (m *mockAuthClient) GetCertAuthority(ctx context.Context, id types.CertAuth return m.server.GetCertAuthority(ctx, id, loadKeys) } -func (m *mockAuthClient) PerformMFACeremony(ctx context.Context, challengeRequest *proto.CreateAuthenticateChallengeRequest, promptOpts ...mfa.PromptOpt) (*proto.MFAAuthenticateResponse, error) { +func (m *mockAuthClient) PerformMFACeremony(ctx context.Context, challengeRequest *clientpb.CreateAuthenticateChallengeRequest, promptOpts ...mfa.PromptOpt) (*clientpb.MFAAuthenticateResponse, error) { // return MFA not required to gracefully skip the MFA prompt. return nil, &mfa.ErrMFANotRequired } @@ -77,6 +83,8 @@ func (m *mockIntegrationsClient) ExportIntegrationCertAuthorities(ctx context.Co } func TestExportAuthorities(t *testing.T) { + t.Parallel() + ctx := context.Background() const localClusterName = "localcluster" @@ -123,206 +131,421 @@ func TestExportAuthorities(t *testing.T) { require.Contains(t, s, fixtures.SSHCAPublicKey) } - for _, exportSecrets := range []bool{false, true} { - for _, tt := range []struct { - name string - req ExportAuthoritiesRequest - errorCheck require.ErrorAssertionFunc - assertNoSecrets func(t *testing.T, output string) - assertSecrets func(t *testing.T, output string) - }{ - { - name: "ssh host and user ca", - req: ExportAuthoritiesRequest{ - AuthType: "", - }, - errorCheck: require.NoError, - assertNoSecrets: func(t *testing.T, output string) { - require.Contains(t, output, "@cert-authority localcluster,*.localcluster ecdsa-sha2-nistp256") - require.Contains(t, output, "cert-authority ecdsa-sha2-nistp256") - }, - assertSecrets: func(t *testing.T, output string) {}, - }, - { - name: "user", - req: ExportAuthoritiesRequest{ - AuthType: "user", - }, - errorCheck: require.NoError, - assertNoSecrets: func(t *testing.T, output string) { - require.Contains(t, output, "cert-authority ecdsa-sha2-nistp256") - }, - assertSecrets: validatePrivateKeyPEMFunc, - }, - { - name: "host", - req: ExportAuthoritiesRequest{ - AuthType: "host", - }, - errorCheck: require.NoError, - assertNoSecrets: func(t *testing.T, output string) { - require.Contains(t, output, "@cert-authority localcluster,*.localcluster ecdsa-sha2-nistp256") - }, - assertSecrets: validatePrivateKeyPEMFunc, - }, - { - name: "tls", - req: ExportAuthoritiesRequest{ - AuthType: "tls", - }, - errorCheck: require.NoError, - assertNoSecrets: validateTLSCertificatePEMFunc, - assertSecrets: validatePrivateKeyPEMFunc, - }, - { - name: "windows", - req: ExportAuthoritiesRequest{ - AuthType: "windows", - }, - errorCheck: require.NoError, - assertNoSecrets: validateTLSCertificateDERFunc, - assertSecrets: validateECDSAPrivateKeyDERFunc, - }, - { - name: "invalid", - req: ExportAuthoritiesRequest{ - AuthType: "invalid", - }, - errorCheck: func(tt require.TestingT, err error, i ...interface{}) { - require.ErrorContains(tt, err, `"invalid" authority type is not supported`) - }, - }, - { - name: "fingerprint not found", - req: ExportAuthoritiesRequest{ - AuthType: "user", - ExportAuthorityFingerprint: "not found fingerprint", - }, - errorCheck: require.NoError, - assertNoSecrets: func(t *testing.T, output string) { - require.Empty(t, output) - }, - assertSecrets: func(t *testing.T, output string) { - require.Empty(t, output) - }, - }, - { - name: "fingerprint not found", - req: ExportAuthoritiesRequest{ - AuthType: "user", - ExportAuthorityFingerprint: "fake fingerprint", - }, - errorCheck: require.NoError, - assertNoSecrets: func(t *testing.T, output string) { - require.Empty(t, output) - }, - assertSecrets: func(t *testing.T, output string) { - require.Empty(t, output) - }, - }, - { - name: "using compat version", - req: ExportAuthoritiesRequest{ - AuthType: "user", - UseCompatVersion: true, - }, - errorCheck: require.NoError, - assertNoSecrets: func(t *testing.T, output string) { - // compat version (using 1.0) returns cert-authority to be used in the server - // even when asking for ssh authorized hosts / known hosts - require.Contains(t, output, "@cert-authority localcluster,*.localcluster ecdsa-sha2-nistp256") - }, - assertSecrets: validatePrivateKeyPEMFunc, - }, - { - name: "db", - req: ExportAuthoritiesRequest{ - AuthType: "db", - }, - errorCheck: require.NoError, - assertNoSecrets: validateTLSCertificatePEMFunc, - assertSecrets: validatePrivateKeyPEMFunc, - }, - { - name: "db-der", - req: ExportAuthoritiesRequest{ - AuthType: "db-der", - }, - errorCheck: require.NoError, - assertNoSecrets: validateTLSCertificateDERFunc, - assertSecrets: validateRSAPrivateKeyDERFunc, - }, - { - name: "db-client", - req: ExportAuthoritiesRequest{ - AuthType: "db-client", - }, - errorCheck: require.NoError, - assertNoSecrets: validateTLSCertificatePEMFunc, - assertSecrets: validatePrivateKeyPEMFunc, - }, - { - name: "db-client-der", - req: ExportAuthoritiesRequest{ - AuthType: "db-client-der", - }, - errorCheck: require.NoError, - 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, - }, - } { - t.Run(fmt.Sprintf("%s_exportSecrets_%v", tt.name, exportSecrets), func(t *testing.T) { - mockedClient := &mockAuthClient{ - server: testAuth.AuthServer, - integrationsClient: mockIntegrationsClient{ - caKeySet: &types.CAKeySet{ - SSH: []*types.SSHKeyPair{{ - PublicKey: []byte(fixtures.SSHCAPublicKey), - }}, - }, - }, - } - var ( - err error - exported string - ) - exportFunc := ExportAuthorities - checkFunc := tt.assertNoSecrets - - if exportSecrets { - exportFunc = ExportAuthoritiesSecrets - checkFunc = tt.assertSecrets - } + mockedAuthClient := &mockAuthClient{ + server: testAuth.AuthServer, + integrationsClient: mockIntegrationsClient{ + caKeySet: &types.CAKeySet{ + SSH: []*types.SSHKeyPair{{ + PublicKey: []byte(fixtures.SSHCAPublicKey), + }}, + }, + }, + } - if checkFunc == nil { - t.Skip("assert func not provided") - } + for _, tt := range []struct { + name string + req ExportAuthoritiesRequest + errorCheck require.ErrorAssertionFunc + assertNoSecrets func(t *testing.T, output string) + assertSecrets func(t *testing.T, output string) + skipSecrets bool + }{ + { + name: "ssh host and user ca", + req: ExportAuthoritiesRequest{ + AuthType: "", + }, + errorCheck: require.NoError, + assertNoSecrets: func(t *testing.T, output string) { + require.Contains(t, output, "@cert-authority localcluster,*.localcluster ecdsa-sha2-nistp256") + require.Contains(t, output, "cert-authority ecdsa-sha2-nistp256") + }, + assertSecrets: func(t *testing.T, output string) {}, + }, + { + name: "user", + req: ExportAuthoritiesRequest{ + AuthType: "user", + }, + errorCheck: require.NoError, + assertNoSecrets: func(t *testing.T, output string) { + require.Contains(t, output, "cert-authority ecdsa-sha2-nistp256") + }, + assertSecrets: validatePrivateKeyPEMFunc, + }, + { + name: "host", + req: ExportAuthoritiesRequest{ + AuthType: "host", + }, + errorCheck: require.NoError, + assertNoSecrets: func(t *testing.T, output string) { + require.Contains(t, output, "@cert-authority localcluster,*.localcluster ecdsa-sha2-nistp256") + }, + assertSecrets: validatePrivateKeyPEMFunc, + }, + { + name: "tls", + req: ExportAuthoritiesRequest{ + AuthType: "tls", + }, + errorCheck: require.NoError, + assertNoSecrets: validateTLSCertificatePEMFunc, + assertSecrets: validatePrivateKeyPEMFunc, + }, + { + name: "windows", + req: ExportAuthoritiesRequest{ + AuthType: "windows", + }, + errorCheck: require.NoError, + assertNoSecrets: validateTLSCertificateDERFunc, + assertSecrets: validateECDSAPrivateKeyDERFunc, + }, + { + name: "invalid", + req: ExportAuthoritiesRequest{ + AuthType: "invalid", + }, + errorCheck: func(tt require.TestingT, err error, i ...interface{}) { + require.ErrorContains(tt, err, `"invalid" authority type is not supported`) + }, + }, + { + name: "fingerprint not found", + req: ExportAuthoritiesRequest{ + AuthType: "user", + ExportAuthorityFingerprint: "not found fingerprint", + }, + errorCheck: require.NoError, + assertNoSecrets: func(t *testing.T, output string) { + require.Empty(t, output) + }, + assertSecrets: func(t *testing.T, output string) { + require.Empty(t, output) + }, + }, + { + name: "fingerprint not found", + req: ExportAuthoritiesRequest{ + AuthType: "user", + ExportAuthorityFingerprint: "fake fingerprint", + }, + errorCheck: require.NoError, + assertNoSecrets: func(t *testing.T, output string) { + require.Empty(t, output) + }, + assertSecrets: func(t *testing.T, output string) { + require.Empty(t, output) + }, + }, + { + name: "using compat version", + req: ExportAuthoritiesRequest{ + AuthType: "user", + UseCompatVersion: true, + }, + errorCheck: require.NoError, + assertNoSecrets: func(t *testing.T, output string) { + // compat version (using 1.0) returns cert-authority to be used in the server + // even when asking for ssh authorized hosts / known hosts + require.Contains(t, output, "@cert-authority localcluster,*.localcluster ecdsa-sha2-nistp256") + }, + assertSecrets: validatePrivateKeyPEMFunc, + }, + { + name: "db", + req: ExportAuthoritiesRequest{ + AuthType: "db", + }, + errorCheck: require.NoError, + assertNoSecrets: validateTLSCertificatePEMFunc, + assertSecrets: validatePrivateKeyPEMFunc, + }, + { + name: "db-der", + req: ExportAuthoritiesRequest{ + AuthType: "db-der", + }, + errorCheck: require.NoError, + assertNoSecrets: validateTLSCertificateDERFunc, + assertSecrets: validateRSAPrivateKeyDERFunc, + }, + { + name: "db-client", + req: ExportAuthoritiesRequest{ + AuthType: "db-client", + }, + errorCheck: require.NoError, + assertNoSecrets: validateTLSCertificatePEMFunc, + assertSecrets: validatePrivateKeyPEMFunc, + }, + { + name: "db-client-der", + req: ExportAuthoritiesRequest{ + AuthType: "db-client-der", + }, + errorCheck: require.NoError, + 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, + exportFunc func(context.Context, authclient.ClientI, ExportAuthoritiesRequest) ([]*ExportedAuthority, error), + assertFunc func(t *testing.T, output string), + ) { + authorities, err := exportFunc(ctx, mockedAuthClient, tt.req) + tt.errorCheck(t, err) + if err != nil { + return + } + + require.Len(t, authorities, 1, "exported authorities mismatch") + exported := string(authorities[0].Data) + assertFunc(t, exported) + } + + runUnaryTest := func( + t *testing.T, + exportFunc func(context.Context, authclient.ClientI, ExportAuthoritiesRequest) (string, error), + assertFunc func(t *testing.T, output string), + ) { + exported, err := exportFunc(ctx, mockedAuthClient, tt.req) + tt.errorCheck(t, err) + if err != nil { + return + } - exported, err = exportFunc(ctx, mockedClient, tt.req) - tt.errorCheck(t, err) + assertFunc(t, exported) + } + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + t.Run(fmt.Sprintf("%s/ExportAllAuthorities", tt.name), func(t *testing.T) { + runTest(t, ExportAllAuthorities, tt.assertNoSecrets) + }) + t.Run(fmt.Sprintf("%s/ExportAuthorities", tt.name), func(t *testing.T) { + runUnaryTest(t, ExportAuthorities, tt.assertNoSecrets) + }) + if tt.skipSecrets { + return + } + + t.Run(fmt.Sprintf("%s/ExportAllAuthoritiesSecrets", tt.name), func(t *testing.T) { + runTest(t, ExportAllAuthoritiesSecrets, tt.assertSecrets) + }) + t.Run(fmt.Sprintf("%s/ExportAuthoritiesSecrets", tt.name), func(t *testing.T) { + runUnaryTest(t, ExportAuthoritiesSecrets, tt.assertSecrets) + }) + }) + } +} - if err != nil { - return +// Tests a scenario similar to +// https://github.com/gravitational/teleport/issues/35444. +func TestExportAllAuthorities_mutipleActiveKeys(t *testing.T) { + t.Parallel() + + softwareKey, err := cryptosuites.GeneratePrivateKeyWithAlgorithm(cryptosuites.ECDSAP256) + require.NoError(t, err, "GeneratePrivateKeyWithAlgorithm errored") + // Typically the HSM key would be RSA2048, but this is fine for testing + // purposes. + hsmKey, err := cryptosuites.GeneratePrivateKeyWithAlgorithm(cryptosuites.ECDSAP256) + require.NoError(t, err, "GeneratePrivateKeyWithAlgorithm errored") + + makeSerialNumber := func() func() *big.Int { + lastSerialNumber := int64(0) + return func() *big.Int { + lastSerialNumber++ + return big.NewInt(lastSerialNumber) + } + }() + + const clusterName = "zarq" // fake, doesn't matter for this test. + makeKeyPairs := func(t *testing.T, key *keys.PrivateKey, keyType types.PrivateKeyType) (sshKP *types.SSHKeyPair, tlsPEM, tlsDER *types.TLSKeyPair) { + sshPriv, err := key.MarshalSSHPrivateKey() + require.NoError(t, err, "MarshalSSHPrivateKey errored") + sshKP = &types.SSHKeyPair{ + PublicKey: key.MarshalSSHPublicKey(), + PrivateKey: sshPriv, + PrivateKeyType: keyType, + } + + serialNumber := makeSerialNumber() + subject := pkix.Name{ + Organization: []string{clusterName}, + SerialNumber: serialNumber.String(), + CommonName: clusterName, + } + now := time.Now() + // template mimics an actual user CA certificate. + template := &x509.Certificate{ + SerialNumber: serialNumber, + Issuer: subject, + Subject: subject, + NotBefore: now.Add(-1 * time.Second), + NotAfter: now.Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + x509CertDER, err := x509.CreateCertificate(rand.Reader, template, template /* parent */, key.Public(), key.Signer) + require.NoError(t, err, "CreateCertificate errored") + x509CertPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: x509CertDER, + }) + tlsPEM = &types.TLSKeyPair{ + Cert: x509CertPEM, + Key: key.PrivateKeyPEM(), + KeyType: keyType, + } + + block, _ := pem.Decode(tlsPEM.Key) + require.NotNil(t, block, "pem.Decode returned nil block") + // Note that typically types.TLSKeyPair doesn't hold raw/DER data, this is + // only used for test convenience. + tlsDER = &types.TLSKeyPair{ + Cert: x509CertDER, + Key: block.Bytes, + KeyType: keyType, + } + + return sshKP, tlsPEM, tlsDER + } + + softKeySSH, softKeyPEM, softKeyDER := makeKeyPairs(t, softwareKey, types.PrivateKeyType_RAW) + hsmKeySSH, hsmKeyPEM, hsmKeyDER := makeKeyPairs(t, hsmKey, types.PrivateKeyType_PKCS11) + userCA, err := types.NewCertAuthority(types.CertAuthoritySpecV2{ + Type: "user", + ClusterName: clusterName, + ActiveKeys: types.CAKeySet{ + SSH: []*types.SSHKeyPair{ + softKeySSH, + hsmKeySSH, + }, + TLS: []*types.TLSKeyPair{ + softKeyPEM, + hsmKeyPEM, + }, + }, + }) + require.NoError(t, err, "NewCertAuthority(user) errored") + + authClient := &multiCAAuthClient{ + ClientI: nil, + clusterName: clusterName, + certAuthorities: []types.CertAuthority{userCA}, + } + ctx := context.Background() + + tests := []struct { + name string + req *ExportAuthoritiesRequest + wantPublic, wantPrivate []*ExportedAuthority + }{ + { + name: "tls-user", + req: &ExportAuthoritiesRequest{ + AuthType: "tls-user", + }, + wantPublic: []*ExportedAuthority{ + {Data: softKeyPEM.Cert}, + {Data: hsmKeyPEM.Cert}, + }, + wantPrivate: []*ExportedAuthority{ + {Data: softKeyPEM.Key}, + {Data: hsmKeyPEM.Key}, + }, + }, + { + name: "windows", + req: &ExportAuthoritiesRequest{ + AuthType: "windows", + }, + wantPublic: []*ExportedAuthority{ + {Data: softKeyDER.Cert}, + {Data: hsmKeyDER.Cert}, + }, + wantPrivate: []*ExportedAuthority{ + {Data: softKeyDER.Key}, + {Data: hsmKeyDER.Key}, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + runTest := func( + t *testing.T, + exportAllFunc func(context.Context, authclient.ClientI, ExportAuthoritiesRequest) ([]*ExportedAuthority, error), + want []*ExportedAuthority, + ) { + got, err := exportAllFunc(ctx, authClient, *test.req) + require.NoError(t, err, "exportAllFunc errored") + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("Authorities mismatch (-want +got)\n%s", diff) } + } - checkFunc(t, exported) + t.Run("ExportAllAuthorities", func(t *testing.T) { + runTest(t, ExportAllAuthorities, test.wantPublic) + }) + t.Run("ExportAllAuthoritiesSecrets", func(t *testing.T) { + runTest(t, ExportAllAuthoritiesSecrets, test.wantPrivate) }) + }) + } +} + +type multiCAAuthClient struct { + authclient.ClientI + + clusterName string + certAuthorities []types.CertAuthority +} + +func (m *multiCAAuthClient) GetDomainName(context.Context) (string, error) { + return m.clusterName, nil +} + +func (m *multiCAAuthClient) GetCertAuthority(_ context.Context, id types.CertAuthID, loadKeys bool) (types.CertAuthority, error) { + for _, ca := range m.certAuthorities { + if ca.GetType() == id.Type && ca.GetClusterName() == id.DomainName { + if !loadKeys { + ca = ca.WithoutSecrets().(types.CertAuthority) + } + return ca, nil } } + return nil, nil +} + +func (m *multiCAAuthClient) PerformMFACeremony( + context.Context, + *clientpb.CreateAuthenticateChallengeRequest, + ...mfa.PromptOpt, +) (*clientpb.MFAAuthenticateResponse, error) { + // Skip MFA ceremonies. + return nil, &mfa.ErrMFANotRequired }