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
195 changes: 195 additions & 0 deletions api/client/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,24 @@ limitations under the License.
package client

import (
"crypto"
"crypto/tls"
"crypto/x509"
"net"
"os"
"sync"

"github.com/gravitational/trace"
"golang.org/x/crypto/ssh"
"golang.org/x/net/http2"

"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/api/identityfile"
"github.com/gravitational/teleport/api/profile"
"github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/api/utils/sshutils"
)

// Credentials are used to authenticate the API auth client. Some Credentials
Expand Down Expand Up @@ -394,3 +400,192 @@ func configureTLS(c *tls.Config) *tls.Config {

return tlsConfig
}

// DynamicIdentityFileCreds allows a changing identity file to be used as the
// source of authentication for Client. It does not automatically watch the
// identity file or reload on an interval, this is left as an exercise for the
// consumer.
type DynamicIdentityFileCreds struct {
// mu protects the fields that may change if the underlying identity file
// is reloaded.
mu sync.RWMutex
tlsCert *tls.Certificate
tlsRootCAs *x509.CertPool
sshCert *ssh.Certificate
sshKey crypto.Signer
sshKnownHosts []ssh.PublicKey

// Path is the path to the identity file to load and reload.
Path string
}

// NewDynamicIdentityFileCreds returns a DynamicIdentityFileCreds which has
// been initially loaded and is ready for use.
func NewDynamicIdentityFileCreds(path string) (*DynamicIdentityFileCreds, error) {
d := &DynamicIdentityFileCreds{
Path: path,
}
if err := d.Reload(); err != nil {
return nil, trace.Wrap(err)
}
return d, nil
}

// Reload causes the identity file to be re-read from the disk. It will return
// an error if loading the credentials fails.
func (d *DynamicIdentityFileCreds) Reload() error {
id, err := identityfile.ReadFile(d.Path)
if err != nil {
return trace.Wrap(err)
}

// This section is essentially id.TLSConfig()
cert, err := keys.X509KeyPair(id.Certs.TLS, id.PrivateKey)
if err != nil {
return trace.Wrap(err)
}
pool := x509.NewCertPool()
for _, caCerts := range id.CACerts.TLS {
if !pool.AppendCertsFromPEM(caCerts) {
return trace.BadParameter("invalid CA cert PEM")
}
}

// This sections is essentially id.SSHClientConfig()
sshCert, err := sshutils.ParseCertificate(id.Certs.SSH)
if err != nil {
return trace.Wrap(err)
}
sshPrivateKey, err := keys.ParsePrivateKey(id.PrivateKey)
if err != nil {
return trace.Wrap(err)
}
knownHosts, err := sshutils.ParseKnownHosts(id.CACerts.SSH)
if err != nil {
return trace.Wrap(err)
}

d.mu.Lock()
defer d.mu.Unlock()
d.tlsRootCAs = pool
d.tlsCert = &cert
d.sshCert = sshCert
d.sshKey = sshPrivateKey
d.sshKnownHosts = knownHosts
return nil
}

// Dialer returns a dialer for the client to use. This is not used, but is
// needed to implement the Credentials interface.
func (d *DynamicIdentityFileCreds) Dialer(
_ Config,
) (ContextDialer, error) {
// Returning a dialer isn't necessary for this credential.
return nil, trace.NotImplemented("no dialer")
}

// TLSConfig returns TLS configuration. Implementing the Credentials interface.
func (d *DynamicIdentityFileCreds) TLSConfig() (*tls.Config, error) {
// Build a "dynamic" tls.Config which can support a changing cert and root
// CA pool.
cfg := &tls.Config{
// Set the default NextProto of "h2". Based on the value in
// configureTLS()
NextProtos: []string{http2.NextProtoTLS},

// GetClientCertificate is used instead of the static Certificates
// field.
Certificates: nil,
GetClientCertificate: func(
_ *tls.CertificateRequestInfo,
) (*tls.Certificate, error) {
// GetClientCertificate callback is used to allow us to dynamically
// change the certificate when reloaded.
d.mu.RLock()
defer d.mu.RUnlock()
return d.tlsCert, nil
},

// VerifyConnection is used instead of the static RootCAs field.
RootCAs: nil,
// InsecureSkipVerify is forced true to ensure that only our
// VerifyConnection callback is used to verify the server's presented
// certificate.
InsecureSkipVerify: true,
VerifyConnection: func(state tls.ConnectionState) error {
// This VerifyConnection callback is based on the standard library
// implementation of verifyServerCertificate in the `tls` package.
// We provide our own implementation so we can dynamically handle
// a changing CA Roots pool.
d.mu.RLock()
defer d.mu.RUnlock()
opts := x509.VerifyOptions{
DNSName: state.ServerName,
Intermediates: x509.NewCertPool(),
Roots: d.tlsRootCAs,
}
for _, cert := range state.PeerCertificates[1:] {
// Whilst we don't currently use intermediate certs at
// Teleport, including this here means that we are
// future-proofed in case we do.
opts.Intermediates.AddCert(cert)
}
_, err := state.PeerCertificates[0].Verify(opts)
return err
},
// Set ServerName for SNI & Certificate Validation to the sentinel
// teleport.cluster.local which is included on all Teleport Auth Server
// certificates. Based on the value in configureTLS()
ServerName: constants.APIDomain,
}

return cfg, nil
}

// SSHClientConfig returns SSH configuration, implementing the Credentials
// interface.
func (d *DynamicIdentityFileCreds) SSHClientConfig() (*ssh.ClientConfig, error) {
// This lock is necessary for `d.sshCert` used in `cfg.User`.
d.mu.RLock()
defer d.mu.RUnlock()
// Build a "dynamic" ssh config. Based roughly on
// `sshutils.ProxyClientSSHConfig` with modifications to make it work with
// dynamically changing credentials and CAs.
cfg := &ssh.ClientConfig{
Auth: []ssh.AuthMethod{
ssh.PublicKeysCallback(func() (signers []ssh.Signer, err error) {
d.mu.RLock()
defer d.mu.RUnlock()
sshSigner, err := sshutils.SSHSigner(d.sshCert, d.sshKey)
if err != nil {
return nil, trace.Wrap(err)
}
return []ssh.Signer{sshSigner}, nil
}),
},
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
d.mu.RLock()
defer d.mu.RUnlock()
hostKeyCallback, err := sshutils.HostKeyCallback(
d.sshKnownHosts,
false,
)
if err != nil {
return trace.Wrap(err)
}
return hostKeyCallback(hostname, remote, key)
},
Timeout: defaults.DefaultIOTimeout,
User: userFromSSHCert(d.sshCert),
}
return cfg, nil
}

func userFromSSHCert(c *ssh.Certificate) string {
// The KeyId is not always a valid principal, so we prefer the first valid
// principal.
if len(c.ValidPrincipals) > 0 {
return c.ValidPrincipals[0]
}
return c.KeyId
}
85 changes: 85 additions & 0 deletions api/client/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@ limitations under the License.
package client

import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path"
"path/filepath"
"testing"

Expand All @@ -28,6 +34,7 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"

"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/identityfile"
"github.com/gravitational/teleport/api/profile"
"github.com/gravitational/teleport/api/utils/keys"
Expand Down Expand Up @@ -382,3 +389,81 @@ AIBV1ZA8WqvC+xZrPwmtmN87BHwGjqpE52kbUfcD94k8IqqhPR9oN9uOlcoBzZiS
k53lH1qmEOm9+vrhNwNzpHk4AqDkP+0YDG++B4n0BtJJpw==
Private-MAC: 8951bbe929e0714a61df01bc8fbc5223e3688f174aee29339931984fb9224c7d`)
)

func TestDynamicIdentityFileCreds(t *testing.T) {
dir := t.TempDir()
identityPath := path.Join(dir, "identity")

idFile := &identityfile.IdentityFile{
PrivateKey: keyPEM,
Certs: identityfile.Certs{
TLS: tlsCert,
SSH: sshCert,
},
CACerts: identityfile.CACerts{
TLS: [][]byte{tlsCACert},
SSH: [][]byte{sshCACert},
},
}
require.NoError(t, identityfile.Write(idFile, identityPath))

cred, err := NewDynamicIdentityFileCreds(identityPath)
require.NoError(t, err)

// Check the initial TLS certificate/key has been loaded.
tlsConfig, err := cred.TLSConfig()
require.NoError(t, err)
gotTLSCert, err := tlsConfig.GetClientCertificate(&tls.CertificateRequestInfo{
// We always return the same cert so this can be empty
})
require.NoError(t, err)
wantTLSCert, err := tls.X509KeyPair(tlsCert, keyPEM)
require.NoError(t, err)
require.Equal(t, wantTLSCert, *gotTLSCert)

// Generate a new TLS certificate that contains the same private key as
// the original.
template := &x509.Certificate{
SerialNumber: big.NewInt(0),
Subject: pkix.Name{
CommonName: "example",
},
KeyUsage: x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
DNSNames: []string{constants.APIDomain},
}
secondTLSCert, err := x509.CreateCertificate(
rand.Reader, template, template, &wantTLSCert.PrivateKey.(*rsa.PrivateKey).PublicKey, wantTLSCert.PrivateKey,
)
require.NoError(t, err)
secondTLSCertPem := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: secondTLSCert,
})

// Write the new TLS certificate as part of the identity file and reload.
secondIDFile := &identityfile.IdentityFile{
PrivateKey: keyPEM,
Certs: identityfile.Certs{
TLS: secondTLSCertPem,
SSH: sshCert,
},
CACerts: identityfile.CACerts{
TLS: [][]byte{tlsCACert},
SSH: [][]byte{sshCACert},
},
}
require.NoError(t, identityfile.Write(secondIDFile, identityPath))
require.NoError(t, cred.Reload())

// Test that calling GetClientCertificate on the original tls.Config now
// returns the new certificate we wrote and reloaded.
gotTLSCert, err = tlsConfig.GetClientCertificate(&tls.CertificateRequestInfo{
// We always return the same cert so this can be empty
})
require.NoError(t, err)
wantTLSCert, err = tls.X509KeyPair(secondTLSCertPem, keyPEM)
require.NoError(t, err)
require.Equal(t, wantTLSCert, *gotTLSCert)

}