mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
Dynamic identity file reloading support for API Client (#29139)
* Skeleton of hot-swappable tls credentials for api client * Start work on SSH support * Add note to what needs doing * Support rotating ssh host cert * Tidy u implementation * Further tidy dynamic credential implementation * Add godoc to new function * Move code into credentials.go * Add some more explanatory docs * Fix panics intrdouced by refactor * Add tests & make fix-imports * Add helper for extracting principal
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user