Skip to content

Commit

Permalink
feat: Support TLS client authentication
Browse files Browse the repository at this point in the history
Signed-off-by: jannfis <[email protected]>
  • Loading branch information
jannfis committed Feb 29, 2024
1 parent 942b259 commit 57e14cb
Show file tree
Hide file tree
Showing 8 changed files with 223 additions and 53 deletions.
7 changes: 7 additions & 0 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ func NewAgentRunCommand() *cobra.Command {
agentMode string
creds string
showVersion bool
tlsClientCrt string
tlsClientKey string
)
command := &cobra.Command{
Short: "Run the argocd-agent agent component",
Expand Down Expand Up @@ -68,6 +70,9 @@ func NewAgentRunCommand() *cobra.Command {
} else if rootCAPath != "" {
remoteOpts = append(remoteOpts, client.WithRootAuthoritiesFromFile(rootCAPath))
}
if tlsClientCrt != "" && tlsClientKey != "" {
remoteOpts = append(remoteOpts, client.WithTLSClientCertFromFile(tlsClientCrt, tlsClientKey))
}
remoteOpts = append(remoteOpts, client.WithClientMode(types.AgentModeFromString(agentMode)))
if serverAddress != "" && serverPort > 0 && serverPort < 65536 {
remote, err = client.NewRemote(serverAddress, serverPort, remoteOpts...)
Expand Down Expand Up @@ -106,6 +111,8 @@ func NewAgentRunCommand() *cobra.Command {
command.Flags().StringVar(&agentMode, "agent-mode", "autonomous", "Mode of operation")
command.Flags().StringVar(&creds, "creds", "", "Credentials to use when connecting to server")
command.Flags().BoolVar(&showVersion, "version", false, "Display version information and exit")
command.Flags().StringVar(&tlsClientCrt, "tls-client-cert", "", "Path to TLS client certificate")
command.Flags().StringVar(&tlsClientKey, "tls-client-key", "", "Path to TLS client key")
return command
}

Expand Down
43 changes: 29 additions & 14 deletions cmd/principal/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,23 @@ import (

func NewPrincipalRunCommand() *cobra.Command {
var (
listenHost string
listenPort int
logLevel string
metricsPort int
disableMetrics bool
namespace string
allowedNamespaces []string
kubeConfig string
tlsCert string
tlsKey string
jwtKey string
allowTlsGenerate bool
allowJwtGenerate bool
userDB string
listenHost string
listenPort int
logLevel string
metricsPort int
disableMetrics bool
namespace string
allowedNamespaces []string
kubeConfig string
tlsCert string
tlsKey string
jwtKey string
allowTlsGenerate bool
allowJwtGenerate bool
userDB string
rootCaPath string
requireClientCerts bool
clientCertSubjectMatch bool
)
var command = &cobra.Command{
Short: "Run the argocd-agent principal component",
Expand Down Expand Up @@ -64,12 +67,21 @@ func NewPrincipalRunCommand() *cobra.Command {

if tlsCert != "" && tlsKey != "" {
opts = append(opts, principal.WithTLSKeyPairFromPath(tlsCert, tlsKey))
} else if (tlsCert != "" && tlsKey == "") || (tlsCert == "" && tlsKey != "") {
cmd.Fatal("Both --tls-cert and --tls-key have to be given")
} else if allowTlsGenerate {
opts = append(opts, principal.WithGeneratedTLS("argocd-agent"))
} else {
cmd.Fatal("No TLS configuration given and auto generation not allowed.")
}

if rootCaPath != "" {
opts = append(opts, principal.WithTLSRootCaFromFile(rootCaPath))
}

opts = append(opts, principal.WithRequireClientCerts(requireClientCerts))
opts = append(opts, principal.WithClientCertSubjectMatch(clientCertSubjectMatch))

if jwtKey != "" {
opts = append(opts, principal.WithTokenSigningKeyFromFile(jwtKey))
} else if allowJwtGenerate {
Expand Down Expand Up @@ -135,6 +147,9 @@ func NewPrincipalRunCommand() *cobra.Command {
command.Flags().BoolVar(&allowTlsGenerate, "insecure-tls-generate", false, "INSECURE: Generate and use temporary TLS cert and key")
command.Flags().BoolVar(&allowJwtGenerate, "insecure-jwt-generate", false, "INSECURE: Generate and use temporary JWT signing key")
command.Flags().StringVar(&userDB, "passwd", "", "Path to userpass passwd file")
command.Flags().StringVar(&rootCaPath, "root-ca-path", "", "Path to a file containing root CA certificate for verifying client certs")
command.Flags().BoolVar(&requireClientCerts, "require-client-certs", false, "Whether to require agents to present a client certificate")
command.Flags().BoolVar(&clientCertSubjectMatch, "client-cert-subject-match", false, "Whether a client cert's subject must match the agent name")

return command
}
Expand Down
8 changes: 8 additions & 0 deletions internal/tlsutil/tlsutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,11 @@ func TlsCertFromX509(cert *x509.Certificate, key crypto.PrivateKey) (tls.Certifi

return tlsCert, nil
}

// func X509CertFromFile(path string) (*x509.Certificate, error) {
// b, err := os.ReadFile(path)
// if err != nil {
// return nil, err
// }

// }
25 changes: 19 additions & 6 deletions pkg/client/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package client

import (
"context"
"crypto"
"crypto/tls"
"crypto/x509"
"fmt"
Expand All @@ -11,6 +12,7 @@ import (

"github.com/golang-jwt/jwt/v5"
"github.com/jannfis/argocd-agent/internal/auth"
"github.com/jannfis/argocd-agent/internal/tlsutil"
"github.com/jannfis/argocd-agent/pkg/api/grpc/authapi"
"github.com/jannfis/argocd-agent/pkg/api/grpc/versionapi"
"github.com/jannfis/argocd-agent/pkg/types"
Expand Down Expand Up @@ -86,10 +88,21 @@ func (r *Remote) WithTLSHandShakeTimeout(t time.Duration) RemoteOption {
}
}

// WithTLSClientCerte configures the Remote to present the client cert given
// as certData and private key given as keyData on every outbound connection.
// Both, certData and keyData must be PEM encoded.
func WithTLSClientCert(certData []byte, keyData []byte) RemoteOption {
func WithTLSClientCert(cert *x509.Certificate, key crypto.PrivateKey) RemoteOption {
return func(r *Remote) error {
c, err := tlsutil.TlsCertFromX509(cert, key)
if err != nil {
return err
}
r.tlsConfig.Certificates = append(r.tlsConfig.Certificates, c)
return nil
}
}

// WithTLSClientCertFromBytes configures the Remote to present the client cert
// given as certData and private key given as keyData on every outbound
// connection. Both, certData and keyData must be PEM encoded.
func WithTLSClientCertFromBytes(certData []byte, keyData []byte) RemoteOption {
return func(r *Remote) error {
c, err := tls.X509KeyPair(certData, keyData)
if err != nil {
Expand All @@ -105,9 +118,9 @@ func WithTLSClientCert(certData []byte, keyData []byte) RemoteOption {
// connection.
func WithTLSClientCertFromFile(certPath, keyPath string) RemoteOption {
return func(r *Remote) error {
c, err := tls.LoadX509KeyPair(certPath, keyPath)
c, err := tlsutil.TlsCertFromFile(certPath, keyPath, true)
if err != nil {
return err
return fmt.Errorf("unable to read TLS client cert: %v", err)
}
r.tlsConfig.Certificates = append(r.tlsConfig.Certificates, c)
return nil
Expand Down
76 changes: 68 additions & 8 deletions principal/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,63 @@ package principal
import (
"context"
"encoding/json"
"fmt"

middleware "github.com/grpc-ecosystem/go-grpc-middleware/v2"
"github.com/jannfis/argocd-agent/internal/auth"
"github.com/jannfis/argocd-agent/pkg/types"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)

// clientCertificateMatches checks whether the client certificate credentials
func (s *Server) clientCertificateMatches(ctx context.Context, match string) error {
logCtx := log().WithField("client_addr", addressFromContext(ctx))
if !s.options.clientCertSubjectMatch {
logCtx.Debug("No client cert subject matching requested")
return nil
}
c, ok := peer.FromContext(ctx)
if !ok {
return fmt.Errorf("could not get peer from context")
}
tls, ok := c.AuthInfo.(credentials.TLSInfo)
if !ok {
return fmt.Errorf("connection requires TLS credentials but has none")
}
if len(tls.State.VerifiedChains) < 1 {
return fmt.Errorf("no verified certificates found in TLS cred")
}
cn := tls.State.VerifiedChains[0][0].Subject.CommonName
if match != cn {
return fmt.Errorf("the TLS subject '%s' does not match agent name '%s'", cn, match)
}

logCtx.WithField("client_name", cn).Infof("Successful match of client cert subject '%s'", cn)

// Subject has been matched
return nil
}

// unauthenticated is a wrapper function to return a gRPC unauthenticated
// response to the caller.
func unauthenticated() (context.Context, error) {
return nil, status.Error(codes.Unauthenticated, "invalid authentication data")
}

// addressFromContext returns the peer's IP address from the context
func addressFromContext(ctx context.Context) string {
c, ok := peer.FromContext(ctx)
if !ok {
return "unknown"
}
return c.Addr.String()
}

// authenticate is used as a gRPC interceptor to decide whether a request is
// authenticated or not. If the request is authenticated, authenticate will
// also augment the Context of the request with additional information about
Expand All @@ -22,32 +69,44 @@ import (
// If the request turns out to be unauthenticated, authenticate will
// return an appropriate error.
func (s *Server) authenticate(ctx context.Context) (context.Context, error) {
logCtx := log().WithField("module", "AuthHandler")
logCtx := log().WithField("module", "AuthHandler").WithField("client", addressFromContext(ctx))
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "could not get metadata from request")
logCtx.Error("No metadata in incoming request")
return unauthenticated()
}
jwt, ok := md["authorization"]
if !ok {
return nil, status.Error(codes.Unauthenticated, "no authentication data found")
logCtx.Error("No authorization header in request")
return unauthenticated()
}
claims, err := s.issuer.ValidateAccessToken(jwt[0])
if err != nil {
logCtx.Warnf("Error validating token: %v", err)
return nil, status.Error(codes.Unauthenticated, "invalid authentication data")
return unauthenticated()
}

subject, err := claims.GetSubject()
if err != nil {
logCtx.Warnf("Could not get subject from token: %v", err)
return nil, status.Error(codes.Unauthenticated, "invalid authentication data")
return unauthenticated()
}

var agentInfo auth.AuthSubject
err = json.Unmarshal([]byte(subject), &agentInfo)
if err != nil {
logCtx.Warnf("Could not unmarshal subject from token: %v", err)
return nil, status.Error(codes.Unauthenticated, "invalid authentication data")
return unauthenticated()
}

// If we require client certificates, we enforce any potential rules for
// the certificate here, instead of at time the connection is made.
if s.options.requireClientCerts {
if err := s.clientCertificateMatches(ctx, agentInfo.ClientID); err != nil {
logCtx.Errorf("could not match TLS certificate: %v", err)
return unauthenticated()
}
logCtx.Infof("Matched client cert subject to agent name")
}

// claims at this point is validated and we can propagate values to the
Expand All @@ -64,7 +123,8 @@ func (s *Server) authenticate(ctx context.Context) (context.Context, error) {
}
mode := types.AgentModeFromString(agentInfo.Mode)
if mode == types.AgentModeUnknown {
return nil, status.Error(codes.Unauthenticated, "invalid operation mode")
logCtx.Warnf("Client requested invalid operation mode: %s", agentInfo.Mode)
return unauthenticated()
}
s.setAgentMode(agentInfo.ClientID, mode)
logCtx.WithField("client", agentInfo.ClientID).WithField("mode", agentInfo.Mode).Tracef("Client passed authentication")
Expand All @@ -84,7 +144,7 @@ func (s *Server) unaryAuthInterceptor(ctx context.Context, req any, info *grpc.U
if err != nil {
return nil, err
}
return handler(newCtx, nil)
return handler(newCtx, req)
}

// streamAuthInterceptor is a server interceptor for streaming gRPC requests.
Expand Down
11 changes: 8 additions & 3 deletions principal/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ package principal

import (
"context"
"crypto/tls"
"fmt"
"net"
"strconv"
"strings"
"time"

"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"k8s.io/apimachinery/pkg/util/wait"

"github.com/jannfis/argocd-agent/pkg/api/grpc/authapi"
Expand Down Expand Up @@ -72,17 +72,21 @@ func (s *Server) Listen(ctx context.Context, backoff wait.Backoff) error {
try := 1
bind := fmt.Sprintf("%s:%d", s.options.address, s.options.port)
// It should not be a fatal failure if the listener could not be started.
// Instead, retry with backoff until the context has expired.
// Instead, retry with backoff until the context has expired or the
// number of maximum retries has been exceeded.
err = wait.ExponentialBackoff(backoff, func() (done bool, err error) {
var lerr error
if try == 1 {
log().Debugf("Starting TCP listener on %s", bind)
}
// Even though we load TLS configuration here, we will not use create
// a TLS listener. TLS will be setup using the appropriate grpc-go API
// functions.
s.tlsConfig, lerr = s.loadTLSConfig()
if lerr != nil {
return false, lerr
}
c, lerr = tls.Listen("tcp", bind, s.tlsConfig)
c, lerr = net.Listen("tcp", bind)
if lerr != nil {
log().WithError(err).Debugf("Retrying to start TCP listener on %s (retry %d/%d)", bind, try, listenerRetries)
try += 1
Expand Down Expand Up @@ -131,6 +135,7 @@ func (s *Server) serveGRPC(ctx context.Context, errch chan error) error {
// ),
s.unaryAuthInterceptor,
),
grpc.Creds(credentials.NewTLS(s.tlsConfig)),
)
authSrv, err := auth.NewServer(s.queues, s.authMethods, s.issuer)
if err != nil {
Expand Down
Loading

0 comments on commit 57e14cb

Please sign in to comment.