diff --git a/api/client/credentials.go b/api/client/credentials.go index e5ed9545e1c48..03e9c5e6e8275 100644 --- a/api/client/credentials.go +++ b/api/client/credentials.go @@ -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 @@ -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 +} diff --git a/api/client/credentials_test.go b/api/client/credentials_test.go index 5cf6d63ae1b82..051a9d96b73e4 100644 --- a/api/client/credentials_test.go +++ b/api/client/credentials_test.go @@ -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" @@ -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" @@ -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) + +}