Skip to content

Commit

Permalink
Add client-side functions to export multiple authorities (#51189)
Browse files Browse the repository at this point in the history
* Introduce ExportAll functions

* Refactor tests

* Test ExportAll in existing test suite

* Test ExportAll with multiple active CAs

* Update godocs

* Update godocs
  • Loading branch information
codingllama authored Jan 22, 2025
1 parent 67c3a5e commit e442174
Show file tree
Hide file tree
Showing 2 changed files with 553 additions and 245 deletions.
183 changes: 134 additions & 49 deletions lib/client/ca_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -95,35 +107,100 @@ 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 {
mfaResponse, err := mfa.PerformAdminActionMFACeremony(ctx, client.PerformMFACeremony, true /*allowReuse*/)
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)
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -282,18 +359,20 @@ 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
ret.WriteString(castr)
}
}

return ret.String(), nil
return []*ExportedAuthority{
{Data: []byte(ret.String())},
}, nil
}

type exportTLSAuthorityRequest struct {
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading

0 comments on commit e442174

Please sign in to comment.