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:
Noah Stride
2023-08-28 12:05:35 +00:00
committed by GitHub
parent 8d6b0d625c
commit 7cc2e2808d
2 changed files with 280 additions and 0 deletions
+195
View File
@@ -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
}
+85
View File
@@ -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)
}