From 79439bbf6391e4c479747a34cab6ff49d75ad222 Mon Sep 17 00:00:00 2001 From: Andrei Aaron Date: Thu, 18 Dec 2025 19:10:47 +0200 Subject: [PATCH] feat: add configurable mTLS identity extraction with fallback chain (#3640) Add support for configurable identity attributes in mTLS authentication, allowing identity extraction from CommonName, Subject DN, Email SAN, URI SAN, or DNSName SAN with fallback chain support. Includes regex pattern matching for URI SANs (e.g., SPIFFE workload IDs). - Add MTLSConfig with identity attributes, URISANPattern, and index fields - Implement extractMTLSIdentity with fallback chain logic - Move the mtls tests in the api package to pkg/api/mtls_test.go Signed-off-by: Andrei Aaron --- errors/errors.go | 10 + examples/README.md | 46 + examples/config-mtls-spiffe.json | 37 + examples/config-mtls.json | 35 + pkg/api/authn.go | 111 +- pkg/api/authn_test.go | 706 ------------ pkg/api/config/config.go | 39 + pkg/api/config/config_test.go | 138 +++ pkg/api/controller_test.go | 727 ------------ pkg/api/mtls_test.go | 1860 ++++++++++++++++++++++++++++++ pkg/cli/server/root.go | 98 ++ pkg/cli/server/root_test.go | 366 ++++++ pkg/test/tls/tls.go | 26 + pkg/test/tls/tls_test.go | 24 + 14 files changed, 2788 insertions(+), 1435 deletions(-) create mode 100644 examples/config-mtls-spiffe.json create mode 100644 examples/config-mtls.json create mode 100644 pkg/api/mtls_test.go diff --git a/errors/errors.go b/errors/errors.go index c56954b9..326621c1 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -186,4 +186,14 @@ var ( ErrInvalidEventSinkType = errors.New("invalid sink type") ErrEventSinkAddressEmpty = errors.New("address field cannot be empty") ErrCouldNotCreateHTTPEventTransport = errors.New("default transport is not *http.Transport") + ErrNoIdentityInCommonName = errors.New("no identity found in CommonName") + ErrNoURISANFound = errors.New("no URI SAN found") + ErrURISANIndexOutOfRange = errors.New("URI SAN index out of range") + ErrURISANPatternDidNotMatch = errors.New("URI SAN pattern did not match") + ErrInvalidURISANPattern = errors.New("invalid URI SAN pattern") + ErrNoDNSANFound = errors.New("no DNS SAN found") + ErrDNSANIndexOutOfRange = errors.New("DNS SAN index out of range") + ErrNoEmailSANFound = errors.New("no Email SAN found") + ErrEmailSANIndexOutOfRange = errors.New("Email SAN index out of range") + ErrUnsupportedIdentityAttribute = errors.New("unsupported identity attribute") ) diff --git a/examples/README.md b/examples/README.md index 642cd6b2..9dc66f39 100644 --- a/examples/README.md +++ b/examples/README.md @@ -202,6 +202,52 @@ authentication: }, ``` +By default, mTLS authentication extracts the client identity from the certificate's +Common Name (CN) field. You can configure alternative identity attributes and a fallback +chain using the `mtls` configuration under `auth`: + +``` +"http": { + "auth": { + "mtls": { + "identityAttributes": ["CommonName", "Subject", "Email", "URI", "DNSName"], + "uriSanPattern": "spiffe://example.org/workload/(.*)", + "uriSanIndex": 0, + "dnsSanIndex": 0, + "emailSanIndex": 0 + } + } +} +``` + +**Identity Attributes:** +- `CommonName` or `CN` - Extract identity from the certificate's Common Name (CN) field (default) +- `Subject` or `DN` - Extract identity from the full Subject Distinguished Name (DN) +- `Email` or `rfc822name` - Extract identity from Email SAN (Subject Alternative Name) +- `URI` or `URL` - Extract identity from URI SAN (Subject Alternative Name) +- `DNSName` or `DNS` - Extract identity from DNS SAN (Subject Alternative Name) + +The `identityAttributes` array defines a fallback chain - if the first identity attribute fails to +extract an identity, the next identity attribute is tried, and so on. All identity attribute +names are case-insensitive. + +**URI SAN Pattern:** +When using `URI` as an identity attribute, you can specify a regex pattern to extract +a specific part of the URI. For example, with SPIFFE certificates: +- URI: `spiffe://example.org/workload/testuser` +- Pattern: `spiffe://example.org/workload/(.*)` +- Extracted identity: `testuser` + +If no pattern is specified, the full URI value is used as the identity. + +**SAN Indexes:** +When multiple values exist in a SAN field (URI, DNS, or Email), you can specify +which one to use with the index fields (0-based). Default is 0 (first value). + +**Example Configurations:** +- Basic mTLS with CommonName: `examples/config-mtls.json` +- SPIFFE with URI SAN pattern: `examples/config-mtls-spiffe.json` + ### Passphrase Authentication **Local authentication** is supported via htpasswd file with: diff --git a/examples/config-mtls-spiffe.json b/examples/config-mtls-spiffe.json new file mode 100644 index 00000000..ab1fd119 --- /dev/null +++ b/examples/config-mtls-spiffe.json @@ -0,0 +1,37 @@ +{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "tls": { + "cert": "test/data/server.cert", + "key": "test/data/server.key", + "cacert": "test/data/ca.crt" + }, + "auth": { + "mtls": { + "identityAttributes": ["URI", "CommonName"], + "uriSanPattern": "spiffe://example.org/workload/(.*)" + } + }, + "accessControl": { + "repositories": { + "**": { + "policies": [{ + "users": ["testuser"], + "actions": ["read", "create"] + }], + "defaultPolicy": ["read"] + } + } + } + }, + "log": { + "level": "debug" + } +} + diff --git a/examples/config-mtls.json b/examples/config-mtls.json new file mode 100644 index 00000000..02937d66 --- /dev/null +++ b/examples/config-mtls.json @@ -0,0 +1,35 @@ +{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "tls": { + "cert": "test/data/server.cert", + "key": "test/data/server.key", + "cacert": "test/data/ca.crt" + }, + "auth": { + "mtls": { + "identityAttributes": ["CommonName"] + } + }, + "accessControl": { + "repositories": { + "**": { + "policies": [{ + "users": ["clientuser"], + "actions": ["read", "create"] + }], + "defaultPolicy": ["read"] + } + } + } + }, + "log": { + "level": "debug" + } +} diff --git a/pkg/api/authn.go b/pkg/api/authn.go index b5864296..c4ca49d6 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -13,6 +13,7 @@ import ( "net" "net/http" "os" + "regexp" "slices" "strconv" "strings" @@ -124,8 +125,14 @@ func (amw *AuthnMiddleware) mTLSAuthn(ctlr *Controller, userAc *reqCtx.UserAcces // Extract identity from certificate leafCert := request.TLS.PeerCertificates[0] - identity := leafCert.Subject.CommonName - if identity == "" { + // Get mTLS config from auth config + authConfig := ctlr.Config.CopyAuthConfig() + mtlsConfig := authConfig.GetMTLSConfig() + + identity, err := extractMTLSIdentity(leafCert, mtlsConfig) + if err != nil || identity == "" { + ctlr.Log.Debug().Err(err).Msg("mTLS authentication failed - could not extract identity") + return false, nil } @@ -975,6 +982,106 @@ func GenerateAPIKey(uuidGenerator guuid.Generator, log log.Logger, return apiKey, apiKeyID.String(), err } +// extractIdentityFromCertificate attempts to extract identity from a specific identity attribute. +func extractIdentityFromCertificate(cert *x509.Certificate, identityAttribute string, mtlsConfig *config.MTLSConfig, +) (string, error) { + // Normalize to lowercase for case-insensitive matching + normalizedIdentityAttribute := strings.ToLower(strings.TrimSpace(identityAttribute)) + + switch normalizedIdentityAttribute { + case "commonname", "cn": + if cert.Subject.CommonName == "" { + return "", zerr.ErrNoIdentityInCommonName + } + + return cert.Subject.CommonName, nil + + case "subject", "dn": + return cert.Subject.String(), nil + + case "url", "uri": + if len(cert.URIs) == 0 { + return "", zerr.ErrNoURISANFound + } + idx := 0 + if mtlsConfig != nil { + idx = mtlsConfig.URISANIndex + } + if idx < 0 || idx >= len(cert.URIs) { + return "", fmt.Errorf("%w: %d", zerr.ErrURISANIndexOutOfRange, idx) + } + uri := cert.URIs[idx].String() + + // Apply pattern if specified + if mtlsConfig != nil && mtlsConfig.URISANPattern != "" { + re, err := regexp.Compile(mtlsConfig.URISANPattern) + if err != nil { + return "", fmt.Errorf("%w: %w", zerr.ErrInvalidURISANPattern, err) + } + matches := re.FindStringSubmatch(uri) + if len(matches) < 2 { + return "", fmt.Errorf("%w", zerr.ErrURISANPatternDidNotMatch) + } + + return matches[1], nil // Return first capture group + } + + return uri, nil + + case "dnsname", "dns": + if len(cert.DNSNames) == 0 { + return "", zerr.ErrNoDNSANFound + } + idx := 0 + if mtlsConfig != nil { + idx = mtlsConfig.DNSANIndex + } + if idx < 0 || idx >= len(cert.DNSNames) { + return "", fmt.Errorf("%w: %d", zerr.ErrDNSANIndexOutOfRange, idx) + } + + return cert.DNSNames[idx], nil + + case "email", "rfc822name": + if len(cert.EmailAddresses) == 0 { + return "", zerr.ErrNoEmailSANFound + } + idx := 0 + if mtlsConfig != nil { + idx = mtlsConfig.EmailSANIndex + } + if idx < 0 || idx >= len(cert.EmailAddresses) { + return "", fmt.Errorf("%w: %d", zerr.ErrEmailSANIndexOutOfRange, idx) + } + + return cert.EmailAddresses[idx], nil + + default: + return "", fmt.Errorf("%w: %s", zerr.ErrUnsupportedIdentityAttribute, identityAttribute) + } +} + +// extractMTLSIdentity extracts identity from certificate using configured soidentity attributes with fallback chain. +func extractMTLSIdentity(cert *x509.Certificate, mtlsConfig *config.MTLSConfig) (string, error) { + identityAttributes := []string{"CommonName"} // Default + if mtlsConfig != nil && len(mtlsConfig.IdentityAttibutes) > 0 { + identityAttributes = mtlsConfig.IdentityAttibutes + } + + var cummulatedErr error + + for _, identityAttribute := range identityAttributes { + identity, err := extractIdentityFromCertificate(cert, identityAttribute, mtlsConfig) + if err == nil { + return identity, nil + } + + cummulatedErr = errors.Join(cummulatedErr, err) + } + + return "", fmt.Errorf("no identity found in any configured identity attributes: %w", cummulatedErr) +} + func loadPublicKeyFromFile(path string) (crypto.PublicKey, error) { raw, err := os.ReadFile(path) if err != nil { diff --git a/pkg/api/authn_test.go b/pkg/api/authn_test.go index c58ac723..88cac79f 100644 --- a/pkg/api/authn_test.go +++ b/pkg/api/authn_test.go @@ -45,8 +45,6 @@ import ( tlsutils "zotregistry.dev/zot/v2/pkg/test/tls" ) -var ErrUnexpectedError = errors.New("error: unexpected error") - const ( sessionCookieName = "session" userCookieName = "user" @@ -868,609 +866,6 @@ func TestAPIKeys(t *testing.T) { }) } -func TestMTLSAuthentication(t *testing.T) { - // Create temporary directory for certificates - tempDir := t.TempDir() - - // Generate CA certificate - caCert, caKey, err := tlsutils.GenerateCACert() - if err != nil { - panic(err) - } - caCertPath := path.Join(tempDir, "ca.crt") - err = os.WriteFile(caCertPath, caCert, 0o600) - if err != nil { - panic(err) - } - - // Generate server certificate - serverCertPath := path.Join(tempDir, "server.crt") - serverKeyPath := path.Join(tempDir, "server.key") - opts := &tlsutils.CertificateOptions{ - Hostname: "localhost", - } - err = tlsutils.GenerateServerCertToFile(caCert, caKey, serverCertPath, serverKeyPath, opts) - if err != nil { - panic(err) - } - - // Generate valid client certificate for "testuser" user - clientCertPath := path.Join(tempDir, "client.crt") - clientKeyPath := path.Join(tempDir, "client.key") - clientOpts := &tlsutils.CertificateOptions{ - CommonName: "testuser", - } - err = tlsutils.GenerateClientCertToFile(caCert, caKey, clientCertPath, clientKeyPath, clientOpts) - if err != nil { - panic(err) - } - - // Generate self-signed client cert for "testuser" user - selfSignedClientCertPath := path.Join(tempDir, "client-selfsigned.crt") - selfSignedClientKeyPath := path.Join(tempDir, "client-selfsigned.key") - selfSignedOpts := &tlsutils.CertificateOptions{ - CommonName: "testuser", - } - err = tlsutils.GenerateClientSelfSignedCertToFile(selfSignedClientCertPath, selfSignedClientKeyPath, selfSignedOpts) - if err != nil { - panic(err) - } - - // Create htpasswd file with sample "httpuser" - htpasswdPath := test.MakeHtpasswdFileFromString(t, test.GetBcryptCredString("httpuser", "httppass")) - defer os.Remove(htpasswdPath) - - Convey("Test mTLS-only authentication", t, func() { - // Set up server - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetSecureBaseURL(port) - - conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertPath, - Key: serverKeyPath, - CACert: caCertPath, - } - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Groups: config.Groups{ - "mtls-users": config.Group{ - Users: []string{"testuser"}, - }, - }, - Repositories: config.Repositories{ - "**": config.PolicyGroup{ // Default restrict all - AnonymousPolicy: make([]string, 0), - Policies: make([]config.Policy, 0), - }, - "test-repo": config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{"testuser"}, - Actions: []string{"read", "create"}, - }, - }, - }, - }, - } - conf.Storage.RootDirectory = t.TempDir() - - ctlr := api.NewController(conf) - cm := test.NewControllerManager(ctlr) - - cm.StartAndWait(port) - defer cm.StopServer() - - // Test without client certificate - should fail - caCertPEM, err := os.ReadFile(caCertPath) - So(err, ShouldBeNil) - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCertPEM) - - client := resty.New() - client.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS13}) - resp, err := client.R().Get(baseURL + "/v2/test-repo/tags/list") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - - // Test with valid client certificate - should succeed - clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) - So(err, ShouldBeNil) - - client = resty.New() - client.SetTLSClientConfig(&tls.Config{ - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{clientCert}, - RootCAs: caCertPool, - }) - - resp, err = client.R().Get(baseURL + "/v2/test-repo/tags/list") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth - - // Test with self-signed client certificate - should fail - selfSignedClientCert, err := tls.LoadX509KeyPair(selfSignedClientCertPath, selfSignedClientKeyPath) - So(err, ShouldBeNil) - - client = resty.New() - client.SetTLSClientConfig(&tls.Config{ - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{selfSignedClientCert}, - RootCAs: caCertPool, - }) - - resp, err = client.R().Get(baseURL + "/v2/test-selfsigned-repo/tags/list") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) - - Convey("Test mTLS with basic auth and user/group access policies", t, func() { - // Set up server - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetSecureBaseURL(port) - - conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertPath, - Key: serverKeyPath, - CACert: caCertPath, - } - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Groups: config.Groups{ - "mtls-users": config.Group{ - Users: []string{"testuser"}, - }, - }, - Repositories: config.Repositories{ - "**": config.PolicyGroup{ // Default restrict all - AnonymousPolicy: make([]string, 0), - Policies: make([]config.Policy, 0), - }, - "group-repo": config.PolicyGroup{ - Policies: []config.Policy{ - { - Groups: []string{"mtls-users"}, - Actions: []string{"read", "create"}, - }, - }, - }, - "test-repo": config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{"testuser"}, - Actions: []string{"read", "create"}, - }, - }, - }, - "htpasswd-repo": config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{"httpuser"}, - Actions: []string{"read", "create"}, - }, - }, - }, - }, - } - conf.Storage.RootDirectory = t.TempDir() - - ctlr := api.NewController(conf) - cm := test.NewControllerManager(ctlr) - - cm.StartAndWait(port) - defer cm.StopServer() - - // Load server CA certificate - caCertPEM, err := os.ReadFile(caCertPath) - So(err, ShouldBeNil) - - // Load self-signed client certificate - selfSignedClientCert, err := tls.LoadX509KeyPair(selfSignedClientCertPath, selfSignedClientKeyPath) - So(err, ShouldBeNil) - - // Load valid client certificate with CN "testuser" - clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) - So(err, ShouldBeNil) - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCertPEM) - - // Tests without client certificate - client := resty.New() - client.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS13}) - resp, err := client.R().SetBasicAuth("httpuser", "httppass").Get(baseURL + "/v2/htpasswd-repo/tags/list") - // Test without client CA but with htpasswd credentials - should pass because of valid htpasswd credentials - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth - - // Tests with self-signed (== non-acceptable by server) client certificate - client = resty.New() - client.SetTLSClientConfig(&tls.Config{ - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{selfSignedClientCert}, - RootCAs: caCertPool, - }) - - // Test with self-signed client certificate - should still pass because of correct htpasswd auth - resp, err = client.R().SetBasicAuth("httpuser", "httppass").Get(baseURL + "/v2/htpasswd-repo/tags/list") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth - - // Tests with valid client certificate - client = resty.New() - client.SetTLSClientConfig(&tls.Config{ - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{clientCert}, - RootCAs: caCertPool, - }) - // Tests with valid client cert and creds - should fail with 403 due to no permissions for user from basic auth - // This validates that identity from basic auth has higher priority over mTLS identity - resp, err = client.R().SetBasicAuth("httpuser", "httppass").Get(baseURL + "/v2/test-repo/tags/list") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) - - // Test with correct auth credentials and different basic auth username from client certificate CN - should success - // This validates that identity from basic auth has higher priority over mTLS identity - resp, err = client.R().SetBasicAuth("httpuser", "httppass").Get(baseURL + "/v2/htpasswd-repo/tags/list") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth - - // Should have access to test-repo for identity from client-cert - resp, err = client.R().Get(baseURL + "/v2/test-repo/tags/list") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth - - // Should not have access to other repos for identity from client-cert - resp, err = client.R().Get(baseURL + "/v2/unauthorized-repo/tags/list") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) - - // Should have access to group-repo through group membership for identity from client-cert - resp, err = client.R().Get(baseURL + "/v2/group-repo/tags/list") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth - }) -} - -func TestMTLSAuthenticationWithCertificateChain(t *testing.T) { - // Create temporary directory for certificates - tempDir := t.TempDir() - - Convey("Test mTLS with certificate chain - uses leaf certificate identity", t, func() { - // Create certificate chain: Root CA -> Intermediate CA -> Client Certificate - // Generate root CA - rootCACert, rootCAKey, err := tlsutils.GenerateCACert() - So(err, ShouldBeNil) - rootCACertPath := path.Join(tempDir, "root-ca.crt") - err = os.WriteFile(rootCACertPath, rootCACert, 0o600) - So(err, ShouldBeNil) - - // Generate intermediate CA (signed by root CA) - intermediateCAOpts := &tlsutils.CertificateOptions{ - CommonName: "Intermediate CA", - } - intermediateCACert, intermediateCAKeyPEM, err := tlsutils.GenerateIntermediateCACert( - rootCACert, rootCAKey, intermediateCAOpts) - So(err, ShouldBeNil) - - // Generate client certificate with CN signed by intermediate CA - clientWithCNOpts := &tlsutils.CertificateOptions{ - CommonName: "clientuser", - } - clientCertWithCN, clientKeyWithCN, err := tlsutils.GenerateClientCert( - intermediateCACert, intermediateCAKeyPEM, clientWithCNOpts) - So(err, ShouldBeNil) - - // Generate client certificate without CN signed by intermediate CA - clientWithoutCNOpts := &tlsutils.CertificateOptions{ - // No CommonName - empty to test that identity is not taken from intermediate CA - } - clientCertWithoutCN, clientKeyWithoutCNPEM, err := tlsutils.GenerateClientCert( - intermediateCACert, intermediateCAKeyPEM, clientWithoutCNOpts) - So(err, ShouldBeNil) - - // Generate server certificate signed by root CA for this test - serverCertForChainPath := path.Join(tempDir, "server-chain.crt") - serverKeyForChainPath := path.Join(tempDir, "server-chain.key") - serverOpts := &tlsutils.CertificateOptions{ - Hostname: "localhost", - } - err = tlsutils.GenerateServerCertToFile( - rootCACert, rootCAKey, serverCertForChainPath, serverKeyForChainPath, serverOpts) - So(err, ShouldBeNil) - - // Set up server with root CA - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetSecureBaseURL(port) - - conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertForChainPath, - Key: serverKeyForChainPath, - CACert: rootCACertPath, // Server trusts root CA - } - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - "**": config.PolicyGroup{ - AnonymousPolicy: make([]string, 0), - Policies: make([]config.Policy, 0), - }, - "client-repo": config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{"clientuser"}, - Actions: []string{"read", "create"}, - }, - }, - }, - }, - } - conf.Storage.RootDirectory = t.TempDir() - - ctlr := api.NewController(conf) - cm := test.NewControllerManager(ctlr) - - cm.StartAndWait(port) - defer cm.StopServer() - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(rootCACert) - - // Test 1: Client cert with CN in chain - should use client cert CN, not intermediate CA CN - clientCertWithCNPath := path.Join(tempDir, "client-with-cn.crt") - clientKeyWithCNPath := path.Join(tempDir, "client-with-cn.key") - err = os.WriteFile(clientCertWithCNPath, clientCertWithCN, 0o600) - So(err, ShouldBeNil) - err = os.WriteFile(clientKeyWithCNPath, clientKeyWithCN, 0o600) - So(err, ShouldBeNil) - - // Create certificate chain file (client cert + intermediate CA) - chainCertPath := path.Join(tempDir, "client-with-cn-chain.crt") - err = tlsutils.WriteCertificateChainToFile(chainCertPath, clientCertWithCN, intermediateCACert) - So(err, ShouldBeNil) - - // Load certificate chain - clientCertChain, err := tls.LoadX509KeyPair(chainCertPath, clientKeyWithCNPath) - So(err, ShouldBeNil) - - client := resty.New() - client.SetTLSClientConfig(&tls.Config{ - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{clientCertChain}, - RootCAs: caCertPool, - }) - - // Should succeed because client cert has CN "clientuser" which matches policy - resp, err := client.R().Get(baseURL + "/v2/client-repo/tags/list") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 means auth passed - - // Test 2: Client cert without CN in chain - should fail, not use intermediate CA CN - clientCertWithoutCNPath := path.Join(tempDir, "client-without-cn.crt") - clientKeyWithoutCNPath := path.Join(tempDir, "client-without-cn.key") - err = os.WriteFile(clientCertWithoutCNPath, clientCertWithoutCN, 0o600) - So(err, ShouldBeNil) - err = os.WriteFile(clientKeyWithoutCNPath, clientKeyWithoutCNPEM, 0o600) - So(err, ShouldBeNil) - - // Create certificate chain file (client cert without CN + intermediate CA) - chainCertWithoutCNPath := path.Join(tempDir, "client-without-cn-chain.crt") - err = tlsutils.WriteCertificateChainToFile(chainCertWithoutCNPath, clientCertWithoutCN, intermediateCACert) - So(err, ShouldBeNil) - - // Load certificate chain - clientCertChainWithoutCN, err := tls.LoadX509KeyPair(chainCertWithoutCNPath, clientKeyWithoutCNPath) - So(err, ShouldBeNil) - - client = resty.New() - client.SetTLSClientConfig(&tls.Config{ - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{clientCertChainWithoutCN}, - RootCAs: caCertPool, - }) - - // Should fail because client cert has no CN, even though intermediate CA has CN - resp, err = client.R().Get(baseURL + "/v2/client-repo/tags/list") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) -} - -func TestMTLSAuthenticationWithExpiredCertificate(t *testing.T) { - // Create temporary directory for certificates - tempDir := t.TempDir() - - Convey("Test mTLS authentication with expired certificate", t, func() { - // Generate CA certificate - caCert, caKey, err := tlsutils.GenerateCACert() - So(err, ShouldBeNil) - caCertPath := path.Join(tempDir, "ca.crt") - err = os.WriteFile(caCertPath, caCert, 0o600) - So(err, ShouldBeNil) - - // Generate server certificate - serverCertPath := path.Join(tempDir, "server.crt") - serverKeyPath := path.Join(tempDir, "server.key") - opts := &tlsutils.CertificateOptions{ - Hostname: "localhost", - } - err = tlsutils.GenerateServerCertToFile(caCert, caKey, serverCertPath, serverKeyPath, opts) - So(err, ShouldBeNil) - - // Generate expired client certificate (NotAfter is in the past) - expiredClientCertPath := path.Join(tempDir, "client-expired.crt") - expiredClientKeyPath := path.Join(tempDir, "client-expired.key") - expiredOpts := &tlsutils.CertificateOptions{ - CommonName: "testuser", - NotBefore: time.Now().Add(-365 * 24 * time.Hour), // 1 year ago - NotAfter: time.Now().Add(-24 * time.Hour), // 1 day ago (expired) - } - err = tlsutils.GenerateClientCertToFile(caCert, caKey, expiredClientCertPath, expiredClientKeyPath, expiredOpts) - So(err, ShouldBeNil) - - // Set up server - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetSecureBaseURL(port) - - conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertPath, - Key: serverKeyPath, - CACert: caCertPath, - } - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - "**": config.PolicyGroup{ - AnonymousPolicy: make([]string, 0), - Policies: make([]config.Policy, 0), - }, - "test-repo": config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{"testuser"}, - Actions: []string{"read", "create"}, - }, - }, - }, - }, - } - conf.Storage.RootDirectory = t.TempDir() - - ctlr := api.NewController(conf) - cm := test.NewControllerManager(ctlr) - - cm.StartAndWait(port) - defer cm.StopServer() - - // Set up client with expired certificate - caCertPEM, err := os.ReadFile(caCertPath) - So(err, ShouldBeNil) - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCertPEM) - - expiredClientCert, err := tls.LoadX509KeyPair(expiredClientCertPath, expiredClientKeyPath) - So(err, ShouldBeNil) - - client := resty.New() - client.SetTLSClientConfig(&tls.Config{ - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{expiredClientCert}, - RootCAs: caCertPool, - }) - - // Expired certificate should be rejected at TLS handshake level - // The TLS stack will reject it before it reaches the application layer - _, err = client.R().Get(baseURL + "/v2/test-repo/tags/list") - // Error is expected - TLS handshake fails with expired certificate - So(err, ShouldNotBeNil) - So(err.Error(), ShouldContainSubstring, "expired certificate") - }) -} - -func TestMTLSAuthenticationWithUnknownCA(t *testing.T) { - // Create temporary directory for certificates - tempDir := t.TempDir() - - Convey("Test mTLS authentication with certificate signed by unknown CA", t, func() { - // Generate server CA and certificate - serverCACert, serverCAKey, err := tlsutils.GenerateCACert() - So(err, ShouldBeNil) - serverCACertPath := path.Join(tempDir, "server-ca.crt") - err = os.WriteFile(serverCACertPath, serverCACert, 0o600) - So(err, ShouldBeNil) - - serverCertPath := path.Join(tempDir, "server.crt") - serverKeyPath := path.Join(tempDir, "server.key") - opts := &tlsutils.CertificateOptions{ - Hostname: "localhost", - } - err = tlsutils.GenerateServerCertToFile(serverCACert, serverCAKey, serverCertPath, serverKeyPath, opts) - So(err, ShouldBeNil) - - // Generate a different CA (unknown to the server) and client certificate - unknownCACert, unknownCAKey, err := tlsutils.GenerateCACert() - So(err, ShouldBeNil) - - unknownClientCertPath := path.Join(tempDir, "client-unknown-ca.crt") - unknownClientKeyPath := path.Join(tempDir, "client-unknown-ca.key") - clientOpts := &tlsutils.CertificateOptions{ - CommonName: "testuser", - } - err = tlsutils.GenerateClientCertToFile(unknownCACert, unknownCAKey, unknownClientCertPath, - unknownClientKeyPath, clientOpts) - So(err, ShouldBeNil) - - // Set up server with server CA (doesn't know about unknown CA) - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetSecureBaseURL(port) - - conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertPath, - Key: serverKeyPath, - CACert: serverCACertPath, // Server only trusts serverCACert, not unknownCACert - } - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - "**": config.PolicyGroup{ - AnonymousPolicy: make([]string, 0), - Policies: make([]config.Policy, 0), - }, - "test-repo": config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{"testuser"}, - Actions: []string{"read", "create"}, - }, - }, - }, - }, - } - conf.Storage.RootDirectory = t.TempDir() - - ctlr := api.NewController(conf) - cm := test.NewControllerManager(ctlr) - - cm.StartAndWait(port) - defer cm.StopServer() - - // Set up client with certificate signed by unknown CA - serverCACertPEM, err := os.ReadFile(serverCACertPath) - So(err, ShouldBeNil) - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(serverCACertPEM) - - unknownClientCert, err := tls.LoadX509KeyPair(unknownClientCertPath, unknownClientKeyPath) - So(err, ShouldBeNil) - - client := resty.New() - client.SetTLSClientConfig(&tls.Config{ - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{unknownClientCert}, - RootCAs: caCertPool, - }) - - // Certificate signed by unknown CA should be rejected at TLS handshake level - // The TLS stack will reject it before it reaches the application layer - _, err = client.R().Get(baseURL + "/v2/test-repo/tags/list") - // Error is expected - TLS handshake fails with unknown certificate authority - So(err, ShouldNotBeNil) - So(err.Error(), ShouldContainSubstring, "unknown certificate authority") - }) -} - func TestMultipleAuthorizationHeaders(t *testing.T) { Convey("Test rejection of multiple Authorization headers", t, func() { Convey("Test multiple and single Authorization headers in basic auth handler", func() { @@ -1731,107 +1126,6 @@ func TestMultipleAuthorizationHeaders(t *testing.T) { }) } -func TestMTLSAuthenticationWithMetaDBError(t *testing.T) { - // Create temporary directory for certificates - tempDir := t.TempDir() - - Convey("Test mTLS authentication with MetaDB.SetUserGroups error", t, func() { - // Generate CA certificate - caCert, caKey, err := tlsutils.GenerateCACert() - So(err, ShouldBeNil) - caCertPath := path.Join(tempDir, "ca.crt") - err = os.WriteFile(caCertPath, caCert, 0o600) - So(err, ShouldBeNil) - - // Generate server certificate - serverCertPath := path.Join(tempDir, "server.crt") - serverKeyPath := path.Join(tempDir, "server.key") - opts := &tlsutils.CertificateOptions{ - Hostname: "localhost", - } - err = tlsutils.GenerateServerCertToFile(caCert, caKey, serverCertPath, serverKeyPath, opts) - So(err, ShouldBeNil) - - // Generate valid client certificate for "testuser" user - clientCertPath := path.Join(tempDir, "client.crt") - clientKeyPath := path.Join(tempDir, "client.key") - clientOpts := &tlsutils.CertificateOptions{ - CommonName: "testuser", - } - err = tlsutils.GenerateClientCertToFile(caCert, caKey, clientCertPath, clientKeyPath, clientOpts) - So(err, ShouldBeNil) - - // Set up server - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetSecureBaseURL(port) - - conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertPath, - Key: serverKeyPath, - CACert: caCertPath, - } - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Groups: config.Groups{ - "mtls-users": config.Group{ - Users: []string{"testuser"}, - }, - }, - Repositories: config.Repositories{ - "**": config.PolicyGroup{ - AnonymousPolicy: make([]string, 0), - Policies: make([]config.Policy, 0), - }, - "test-repo": config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{"testuser"}, - Actions: []string{"read", "create"}, - }, - }, - }, - }, - } - conf.Storage.RootDirectory = t.TempDir() - - ctlr := api.NewController(conf) - cm := test.NewControllerManager(ctlr) - - cm.StartAndWait(port) - defer cm.StopServer() - - // Set up client with valid certificate - caCertPEM, err := os.ReadFile(caCertPath) - So(err, ShouldBeNil) - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCertPEM) - - clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) - So(err, ShouldBeNil) - - client := resty.New() - client.SetTLSClientConfig(&tls.Config{ - MinVersion: tls.VersionTLS13, - Certificates: []tls.Certificate{clientCert}, - RootCAs: caCertPool, - }) - - // Mock MetaDB to return error on SetUserGroups - ctlr.MetaDB = mocks.MetaDBMock{ - SetUserGroupsFn: func(ctx context.Context, groups []string) error { - return ErrUnexpectedError - }, - } - - // Should return 500 Internal Server Error due to MetaDB error - resp, err := client.R().Get(baseURL + "/v2/test-repo/tags/list") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - }) -} - func TestAPIKeysOpenDBError(t *testing.T) { Convey("Test API keys - unable to create database", t, func() { conf := config.New() diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index 2b9555e9..c8e78592 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -70,6 +70,34 @@ type TLSConfig struct { CACert string } +type MTLSConfig struct { + // IdentityAttibutes is an ordered list of identity attributes to try + // Options: "CommonName", "Subject", "Email", "URI", "DNSName" (case-insensitive) + // Default: ["CommonName"] (backward compatible) + IdentityAttibutes []string `json:"identityAttributes,omitempty" mapstructure:"identityAttributes,omitempty"` + + // URISANPattern is a regex pattern to extract identity from URI SAN + // Only used when IdentityAttibutes contains "URI" + // Example: "spiffe://example.org/workload/(.*)" extracts the workload ID + // If empty, uses the full URI SAN value + URISANPattern string `json:"uriSanPattern,omitempty" mapstructure:"uriSanPattern,omitempty"` + + // URISANIndex specifies which URI SAN to use if multiple exist (0-based) + // Maps to cert.URIs[index] - the URIs field is a slice, so index is needed + // Default: 0 (first URI) + URISANIndex int `json:"uriSanIndex,omitempty" mapstructure:"uriSanIndex,omitempty"` + + // DNSANIndex specifies which DNS SAN to use if multiple exist (0-based) + // Maps to cert.DNSNames[index] - the DNSNames field is a slice, so index is needed + // Default: 0 (first DNS name) + DNSANIndex int `json:"dnsSanIndex,omitempty" mapstructure:"dnsSanIndex,omitempty"` + + // EmailSANIndex specifies which Email SAN to use if multiple exist (0-based) + // Maps to cert.EmailAddresses[index] - the EmailAddresses field is a slice, so index is needed + // Default: 0 (first email) + EmailSANIndex int `json:"emailSanIndex,omitempty" mapstructure:"emailSanIndex,omitempty"` +} + type AuthHTPasswd struct { Path string } @@ -86,6 +114,7 @@ type AuthConfig struct { SessionEncryptKey []byte `json:"-"` SessionDriver map[string]any `mapstructure:",omitempty"` SecureSession *bool `json:"secureSession,omitempty" mapstructure:"secureSession,omitempty"` + MTLS *MTLSConfig `json:"mtls,omitempty" mapstructure:"mtls,omitempty"` } // IsLdapAuthEnabled checks if LDAP authentication is enabled in this auth config. @@ -141,6 +170,15 @@ func (a *AuthConfig) GetFailDelay() int { return a.FailDelay } +// GetMTLSConfig returns the mTLS configuration if it exists. +func (a *AuthConfig) GetMTLSConfig() *MTLSConfig { + if a == nil { + return nil + } + + return a.MTLS +} + type BearerConfig struct { Realm string Service string @@ -638,6 +676,7 @@ func (c *Config) UpdateReloadableConfig(newConfig *Config) { c.HTTP.Auth.APIKey = newConfig.HTTP.Auth.APIKey c.HTTP.Auth.OpenID = newConfig.HTTP.Auth.OpenID c.HTTP.Auth.SecureSession = newConfig.HTTP.Auth.SecureSession + c.HTTP.Auth.MTLS = newConfig.HTTP.Auth.MTLS } // Initialize and update AccessControlConfig diff --git a/pkg/api/config/config_test.go b/pkg/api/config/config_test.go index f33e7771..2c245410 100644 --- a/pkg/api/config/config_test.go +++ b/pkg/api/config/config_test.go @@ -873,6 +873,31 @@ func TestConfig(t *testing.T) { } So(authConfig.GetFailDelay(), ShouldEqual, 5) }) + + Convey("Test GetMTLSConfig()", func() { + // Test with nil AuthConfig + var authConfig *config.AuthConfig = nil + + So(authConfig.GetMTLSConfig(), ShouldBeNil) + + // Test with AuthConfig but nil MTLS + authConfig = &config.AuthConfig{} + So(authConfig.GetMTLSConfig(), ShouldBeNil) + + // Test with AuthConfig and MTLS configured + authConfig = &config.AuthConfig{ + MTLS: &config.MTLSConfig{ + IdentityAttibutes: []string{"CommonName", "URI"}, + URISANPattern: "spiffe://example.org/workload/(.*)", + }, + } + mtlsConfig := authConfig.GetMTLSConfig() + So(mtlsConfig, ShouldNotBeNil) + So(len(mtlsConfig.IdentityAttibutes), ShouldEqual, 2) + So(mtlsConfig.IdentityAttibutes[0], ShouldEqual, "CommonName") + So(mtlsConfig.IdentityAttibutes[1], ShouldEqual, "URI") + So(mtlsConfig.URISANPattern, ShouldEqual, "spiffe://example.org/workload/(.*)") + }) }) Convey("Test LDAPConfig methods", t, func() { @@ -1036,6 +1061,10 @@ func TestConfig(t *testing.T) { "type": "redis", "host": "localhost", }, + MTLS: &config.MTLSConfig{ + IdentityAttibutes: []string{"CommonName"}, + URISANPattern: "spiffe://example.org/workload/(.*)", + }, }, }, } @@ -1049,6 +1078,8 @@ func TestConfig(t *testing.T) { So(authConfig.IsBearerAuthEnabled(), ShouldBeTrue) So(authConfig.IsOpenIDAuthEnabled(), ShouldBeTrue) So(authConfig.IsAPIKeyEnabled(), ShouldBeFalse) + So(authConfig.GetMTLSConfig(), ShouldNotBeNil) + So(authConfig.GetMTLSConfig().IdentityAttibutes[0], ShouldEqual, "CommonName") // Test deep copy isolation by modifying nested structures authConfig.LDAP.Address = "modified-ldap.example.com" @@ -1056,6 +1087,8 @@ func TestConfig(t *testing.T) { authConfig.OpenID.Providers["google"].Scopes[0] = "modified-scope" authConfig.SessionHashKey[0] = 'M' authConfig.SessionDriver["type"] = "modified-driver" + authConfig.MTLS.IdentityAttibutes[0] = "URI" + authConfig.MTLS.URISANPattern = "modified-pattern" // Verify original is unchanged So(cfg.HTTP.Auth.LDAP.Address, ShouldEqual, "ldap.example.com") @@ -1063,6 +1096,8 @@ func TestConfig(t *testing.T) { So(cfg.HTTP.Auth.OpenID.Providers["google"].Scopes[0], ShouldEqual, "openid") So(cfg.HTTP.Auth.SessionHashKey[0], ShouldEqual, byte('h')) So(cfg.HTTP.Auth.SessionDriver["type"], ShouldEqual, "redis") + So(cfg.HTTP.Auth.MTLS.IdentityAttibutes[0], ShouldEqual, "CommonName") + So(cfg.HTTP.Auth.MTLS.URISANPattern, ShouldEqual, "spiffe://example.org/workload/(.*)") }) Convey("Test that returned AuthConfig is isolated when config is updated via UpdateReloadableConfig", func() { @@ -2852,6 +2887,109 @@ func TestConfig(t *testing.T) { }) }) + Convey("Test UpdateReloadableConfig MTLS config updates", t, func() { + Convey("Test MTLS config is updated in UpdateReloadableConfig", func() { + // Create initial config with MTLS + cfg := &config.Config{ + HTTP: config.HTTPConfig{ + Auth: &config.AuthConfig{ + MTLS: &config.MTLSConfig{ + IdentityAttibutes: []string{"CommonName"}, + URISANPattern: "spiffe://old.example.org/workload/(.*)", + URISANIndex: 0, + }, + }, + }, + } + + // Create new config with updated MTLS + newConfig := &config.Config{ + HTTP: config.HTTPConfig{ + Auth: &config.AuthConfig{ + MTLS: &config.MTLSConfig{ + IdentityAttibutes: []string{"URI", "CommonName"}, + URISANPattern: "spiffe://new.example.org/workload/(.*)", + URISANIndex: 1, + DNSANIndex: 2, + }, + }, + }, + } + + // Update the config + cfg.UpdateReloadableConfig(newConfig) + + // Verify MTLS config was updated + So(cfg.HTTP.Auth.MTLS, ShouldNotBeNil) + So(len(cfg.HTTP.Auth.MTLS.IdentityAttibutes), ShouldEqual, 2) + So(cfg.HTTP.Auth.MTLS.IdentityAttibutes[0], ShouldEqual, "URI") + So(cfg.HTTP.Auth.MTLS.IdentityAttibutes[1], ShouldEqual, "CommonName") + So(cfg.HTTP.Auth.MTLS.URISANPattern, ShouldEqual, "spiffe://new.example.org/workload/(.*)") + So(cfg.HTTP.Auth.MTLS.URISANIndex, ShouldEqual, 1) + So(cfg.HTTP.Auth.MTLS.DNSANIndex, ShouldEqual, 2) + }) + + Convey("Test MTLS config is set to nil when new config has nil MTLS", func() { + // Create initial config with MTLS + cfg := &config.Config{ + HTTP: config.HTTPConfig{ + Auth: &config.AuthConfig{ + MTLS: &config.MTLSConfig{ + IdentityAttibutes: []string{"CommonName"}, + }, + }, + }, + } + + // Create new config with nil MTLS + newConfig := &config.Config{ + HTTP: config.HTTPConfig{ + Auth: &config.AuthConfig{ + MTLS: nil, + }, + }, + } + + // Update the config + cfg.UpdateReloadableConfig(newConfig) + + // Verify MTLS config was set to nil + So(cfg.HTTP.Auth.MTLS, ShouldBeNil) + }) + + Convey("Test MTLS config is created when going from nil to non-nil", func() { + // Create initial config with nil MTLS + cfg := &config.Config{ + HTTP: config.HTTPConfig{ + Auth: &config.AuthConfig{ + MTLS: nil, + }, + }, + } + + // Create new config with MTLS + newConfig := &config.Config{ + HTTP: config.HTTPConfig{ + Auth: &config.AuthConfig{ + MTLS: &config.MTLSConfig{ + IdentityAttibutes: []string{"URI"}, + URISANPattern: "spiffe://new.example.org/workload/(.*)", + }, + }, + }, + } + + // Update the config + cfg.UpdateReloadableConfig(newConfig) + + // Verify MTLS config was created + So(cfg.HTTP.Auth.MTLS, ShouldNotBeNil) + So(len(cfg.HTTP.Auth.MTLS.IdentityAttibutes), ShouldEqual, 1) + So(cfg.HTTP.Auth.MTLS.IdentityAttibutes[0], ShouldEqual, "URI") + So(cfg.HTTP.Auth.MTLS.URISANPattern, ShouldEqual, "spiffe://new.example.org/workload/(.*)") + }) + }) + Convey("Test UpdateReloadableConfig Storage.SubPaths logic", t, func() { Convey("Test existing SubPaths are updated", func() { // Create initial config with SubPaths diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index b0475c82..b526db62 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -90,62 +90,6 @@ var ( LDAPUserAttr = "uid" //nolint: gochecknoglobals ) -// setupTestCerts generates CA, server, and client certificates for testing. -// Returns paths to certificate files and PEM data for CA cert. -func setupTestCerts(t *testing.T) ( - string, string, string, string, string, []byte, -) { - t.Helper() - tempDir := t.TempDir() - - // Generate CA certificate (10 years validity, matching gen_certs.sh) - caOpts := &tlsutils.CertificateOptions{ - CommonName: "*", - NotAfter: time.Now().AddDate(10, 0, 0), - } - caCertPEM, caKeyPEM, err := tlsutils.GenerateCACert(caOpts) - if err != nil { - t.Fatalf("Failed to generate CA cert: %v", err) - } - - caCertPath := path.Join(tempDir, "ca.crt") - caKeyPath := path.Join(tempDir, "ca.key") - err = os.WriteFile(caCertPath, caCertPEM, 0o600) - if err != nil { - t.Fatalf("Failed to write CA cert: %v", err) - } - _ = os.WriteFile(caKeyPath, caKeyPEM, 0o600) - - // Generate server certificate - serverCertPath := path.Join(tempDir, "server.cert") - serverKeyPath := path.Join(tempDir, "server.key") - serverOpts := &tlsutils.CertificateOptions{ - Hostname: "127.0.0.1", - CommonName: "*", - OrganizationalUnit: "TestServer", - NotAfter: time.Now().AddDate(10, 0, 0), - } - err = tlsutils.GenerateServerCertToFile(caCertPEM, caKeyPEM, serverCertPath, serverKeyPath, serverOpts) - if err != nil { - t.Fatalf("Failed to generate server cert: %v", err) - } - - // Generate client certificate (10 years validity, matching gen_certs.sh) - clientCertPath := path.Join(tempDir, "client.cert") - clientKeyPath := path.Join(tempDir, "client.key") - clientOpts := &tlsutils.CertificateOptions{ - CommonName: "testclient", - OrganizationalUnit: "TestClient", - NotAfter: time.Now().AddDate(10, 0, 0), - } - err = tlsutils.GenerateClientCertToFile(caCertPEM, caKeyPEM, clientCertPath, clientKeyPath, clientOpts) - if err != nil { - t.Fatalf("Failed to generate client cert: %v", err) - } - - return caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM -} - // setupBearerAuthServerCerts generates CA and server certificates for bearer auth server testing // with a specific key type. Returns paths to server certificate, key, and public key files. func setupBearerAuthServerCerts(t *testing.T, keyType tlsutils.KeyType) ( @@ -2328,104 +2272,6 @@ func TestTLSWithBasicAuthAllowReadAccess(t *testing.T) { }) } -func TestMutualTLSAuthWithUserPermissions(t *testing.T) { - Convey("Make a new controller", t, func() { - caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t) - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCertPEM) - - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - secureBaseURL := test.GetSecureBaseURL(port) - - resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) - - defer func() { resty.SetTLSClientConfig(nil) }() - - conf := config.New() - conf.HTTP.Port = port - - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertPath, - Key: serverKeyPath, - CACert: caCertPath, - } - - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - test.AuthorizationAllRepos: config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{"testclient"}, - Actions: []string{"read"}, - }, - }, - }, - }, - } - - ctlr := makeController(conf, t.TempDir()) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - - defer cm.StopServer() - - resp, err := resty.R().Get(baseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - repoPolicy := conf.HTTP.AccessControl.Repositories[test.AuthorizationAllRepos] - - // setup TLS mutual auth - cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) - So(err, ShouldBeNil) - - // Use separate resty client with certificates, because we cannot perform cleanup with resty.SetCertificates() - client := resty.New().SetTLSClientConfig(&tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - Certificates: []tls.Certificate{cert}, - }) - - // with client certs but without creds, should succeed - resp, err = client.R().Get(secureBaseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, err = client.R().Get(secureBaseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // with creds, should get expected status code - resp, _ = client.R().Get(secureBaseURL) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - // reading a repo should not get 403 - resp, err = client.R().Get(secureBaseURL + "/v2/repo/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - // without creds, writes should fail - resp, err = client.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) - - // empty default authorization and give user the permission to create - repoPolicy.Policies[0].Actions = append(repoPolicy.Policies[0].Actions, "create") - conf.HTTP.AccessControl.Repositories[test.AuthorizationAllRepos] = repoPolicy - resp, err = client.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - }) -} - func TestAuthnErrors(t *testing.T) { Convey("ldap CA certs fail", t, func() { port := test.GetFreePort() @@ -2616,579 +2462,6 @@ func TestAuthnErrors(t *testing.T) { }) } -func TestMutualTLSAuthWithoutCN(t *testing.T) { - Convey("Make a new controller", t, func() { - // Generate certificates without CommonName for client - tempDir := t.TempDir() - caOpts := &tlsutils.CertificateOptions{ - CommonName: "*", - NotAfter: time.Now().AddDate(10, 0, 0), - } - caCertPEM, caKeyPEM, err := tlsutils.GenerateCACert(caOpts) - So(err, ShouldBeNil) - - caCertPath := path.Join(tempDir, "ca.crt") - err = os.WriteFile(caCertPath, caCertPEM, 0o600) - So(err, ShouldBeNil) - - serverCertPath := path.Join(tempDir, "server.cert") - serverKeyPath := path.Join(tempDir, "server.key") - serverOpts := &tlsutils.CertificateOptions{ - Hostname: "127.0.0.1", - CommonName: "*", - OrganizationalUnit: "TestServer", - NotAfter: time.Now().AddDate(10, 0, 0), - } - err = tlsutils.GenerateServerCertToFile(caCertPEM, caKeyPEM, serverCertPath, serverKeyPath, serverOpts) - So(err, ShouldBeNil) - - // Generate client certificate without CommonName (10 years validity, matching gen_certs.sh) - clientCertPath := path.Join(tempDir, "client.cert") - clientKeyPath := path.Join(tempDir, "client.key") - clientOpts := &tlsutils.CertificateOptions{ - // CommonName intentionally not set - NotAfter: time.Now().AddDate(10, 0, 0), - } - err = tlsutils.GenerateClientCertToFile(caCertPEM, caKeyPEM, clientCertPath, clientKeyPath, clientOpts) - So(err, ShouldBeNil) - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCertPEM) - - port := test.GetFreePort() - secureBaseURL := test.GetSecureBaseURL(port) - - resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) - - defer func() { resty.SetTLSClientConfig(nil) }() - - conf := config.New() - conf.HTTP.Port = port - - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertPath, - Key: serverKeyPath, - CACert: caCertPath, - } - - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - test.AuthorizationAllRepos: config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{"*"}, - Actions: []string{"read"}, - }, - }, - }, - }, - } - - ctlr := makeController(conf, t.TempDir()) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - - defer cm.StopServer() - - // setup TLS mutual auth - cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) - So(err, ShouldBeNil) - - // Use separate resty client with certificates, because we cannot perform cleanup with resty.SetCertificates() - client := resty.New().SetTLSClientConfig(&tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - Certificates: []tls.Certificate{cert}, - }) - - // with client certs but without TLS mutual auth setup should get certificate error - resp, _ := client.R().Get(secureBaseURL + "/v2/_catalog") - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) -} - -func TestTLSMutualAuth(t *testing.T) { - Convey("Make a new controller", t, func() { - caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t) - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCertPEM) - - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - secureBaseURL := test.GetSecureBaseURL(port) - - conf := config.New() - conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertPath, - Key: serverKeyPath, - CACert: caCertPath, - } - - ctlr := makeController(conf, t.TempDir()) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - - defer cm.StopServer() - - // access without any certificate settings - client := resty.New() - - // accessing insecure HTTP site should fail - resp, err := client.R().Get(baseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // without client certs and creds, should get certificate verification error - _, err = client.R().Get(secureBaseURL) - So(err, ShouldNotBeNil) - - // without client certs should fail auth - _, err = client.R().Get(secureBaseURL + "/v2/") - So(err, ShouldNotBeNil) - - // Use resty client with certificates, - client = resty.New().SetTLSClientConfig(&tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - }) - - // without client certs should fail auth - resp, err = client.R().Get(secureBaseURL) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - // without client certs should fail auth - resp, _ = client.R().Get(secureBaseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - - username, seedUser := test.GenerateRandomString() - password, seedPass := test.GenerateRandomString() - ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password") - - resp, err = client.R().SetBasicAuth(username, password).Get(secureBaseURL) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - // with only creds, should get 401 because basic auth is disabled - // (Authorization header should be rejected when the auth method is disabled, regardless of mTLS) - resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - - // setup TLS mutual auth - cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) - So(err, ShouldBeNil) - - client = resty.New().SetTLSClientConfig(&tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - Certificates: []tls.Certificate{cert}, - }) - - // with client certs but without creds, should succeed - resp, err = client.R().Get(secureBaseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - // with client certs and creds, should get 401 because basic auth is disabled - // (Authorization header should be rejected when the auth method is disabled, regardless of mTLS) - resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) -} - -func TestTSLFailedReadingOfCACert(t *testing.T) { - Convey("no permissions", t, func() { - caCertPath, serverCertPath, serverKeyPath, _, _, _ := setupTestCerts(t) - port := test.GetFreePort() - conf := config.New() - conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertPath, - Key: serverKeyPath, - CACert: caCertPath, - } - - err := os.Chmod(caCertPath, 0o000) - defer func() { - err := os.Chmod(caCertPath, 0o644) - So(err, ShouldBeNil) - }() - So(err, ShouldBeNil) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - ctlr := makeController(conf, t.TempDir()) - - err = ctlr.Init() - So(err, ShouldBeNil) - - errChan := make(chan error, 1) - - go func() { - err = ctlr.Run() - errChan <- err - }() - - testTimeout := false - - select { - case err := <-errChan: - So(err, ShouldNotBeNil) - case <-ctx.Done(): - testTimeout = true - - cancel() - } - - So(testTimeout, ShouldBeFalse) - }) - - Convey("empty CACert", t, func() { - badCACert := filepath.Join(t.TempDir(), "badCACert") - err := os.WriteFile(badCACert, []byte(""), 0o600) - So(err, ShouldBeNil) - - _, serverCertPath, serverKeyPath, _, _, _ := setupTestCerts(t) - port := test.GetFreePort() - conf := config.New() - conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertPath, - Key: serverKeyPath, - CACert: badCACert, - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - ctlr := makeController(conf, t.TempDir()) - - err = ctlr.Init() - So(err, ShouldBeNil) - - errChan := make(chan error, 1) - - go func() { - err = ctlr.Run() - errChan <- err - }() - - testTimeout := false - - select { - case err := <-errChan: - So(err, ShouldNotBeNil) - case <-ctx.Done(): - testTimeout = true - - cancel() - } - - So(testTimeout, ShouldBeFalse) - }) -} - -func TestTLSMutualAuthAllowReadAccess(t *testing.T) { - Convey("Make a new controller", t, func() { - caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t) - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCertPEM) - - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - secureBaseURL := test.GetSecureBaseURL(port) - - // Use resty client with certificates, - client := resty.New().SetTLSClientConfig(&tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - }) - - conf := config.New() - conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertPath, - Key: serverKeyPath, - CACert: caCertPath, - } - - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - test.AuthorizationAllRepos: config.PolicyGroup{ - AnonymousPolicy: []string{"read"}, - }, - }, - } - - ctlr := makeController(conf, t.TempDir()) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - - defer cm.StopServer() - - // accessing insecure HTTP site should fail - resp, err := client.R().Get(baseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // without client certs and creds, reads are allowed - resp, err = client.R().Get(secureBaseURL + "/v2/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - username, seedUser := test.GenerateRandomString() - password, seedPass := test.GenerateRandomString() - - ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password") - // with creds but without certs, reads are not allowed as server does not use basic auth - // and basic auth headers are expected to contain valid credentials - resp, err = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - - // without creds, writes should fail - resp, err = client.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - - // setup TLS mutual auth - cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) - So(err, ShouldBeNil) - - // Use separate resty client with certificates, because we cannot perform cleanup with resty.SetCertificates() - client = resty.New().SetTLSClientConfig(&tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - Certificates: []tls.Certificate{cert}, - }) - - // with client certs but without creds, should succeed - resp, _ = client.R().Get(secureBaseURL) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - // with client certs but without creds, should succeed - resp, err = client.R().Get(secureBaseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // with client certs and creds, reads are not allowed as server does not use basic auth - // and basic auth headers are expected to contain valid credentials - resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - // with client certs, reads are not allowed as server does not use basic auth - resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) -} - -func TestTLSMutualAndBasicAuth(t *testing.T) { - Convey("Make a new controller", t, func() { - caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t) - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCertPEM) - - username, seedUser := test.GenerateRandomString() - password, seedPass := test.GenerateRandomString() - - htpasswdPath := test.MakeHtpasswdFileFromString(t, test.GetBcryptCredString(username, password)) - - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - secureBaseURL := test.GetSecureBaseURL(port) - - resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) - - defer func() { resty.SetTLSClientConfig(nil) }() - - conf := config.New() - conf.HTTP.Port = port - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertPath, - Key: serverKeyPath, - CACert: caCertPath, - } - - ctlr := makeController(conf, t.TempDir()) - ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password") - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - - defer cm.StopServer() - - // accessing insecure HTTP site should fail - resp, err := resty.R().Get(baseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // without client certs and creds, should fail - _, err = resty.R().Get(secureBaseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // with creds but without certs, should succeed - _, err = resty.R().SetBasicAuth(username, password).Get(secureBaseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // setup TLS mutual auth - cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) - So(err, ShouldBeNil) - - // Use separate resty client with certificates, because we cannot perform cleanup with resty.SetCertificates() - client := resty.New().SetTLSClientConfig(&tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - Certificates: []tls.Certificate{cert}, - }) - - // with client certs but without creds, succeed because mTLS is used for auth when no auth headers provided - resp, err = client.R().Get(secureBaseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // with client certs and creds, should get expected status code - resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - }) -} - -func TestTLSMutualAndBasicAuthAllowReadAccess(t *testing.T) { - Convey("Make a new controller", t, func() { - caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t) - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCertPEM) - - username, seedUser := test.GenerateRandomString() - password, seedPass := test.GenerateRandomString() - - htpasswdPath := test.MakeHtpasswdFileFromString(t, test.GetBcryptCredString(username, password)) - - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - secureBaseURL := test.GetSecureBaseURL(port) - - resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) - - defer func() { resty.SetTLSClientConfig(nil) }() - - conf := config.New() - conf.HTTP.Port = port - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - conf.HTTP.TLS = &config.TLSConfig{ - Cert: serverCertPath, - Key: serverKeyPath, - CACert: caCertPath, - } - - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - test.AuthorizationAllRepos: config.PolicyGroup{ - AnonymousPolicy: []string{"read"}, - }, - }, - } - - ctlr := makeController(conf, t.TempDir()) - ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password") - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - - defer cm.StopServer() - - // accessing insecure HTTP site should fail - resp, err := resty.R().Get(baseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // without client certs and creds, should fail - _, err = resty.R().Get(secureBaseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // with creds but without certs, should succeed - _, err = resty.R().SetBasicAuth(username, password).Get(secureBaseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // setup TLS mutual auth - cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) - So(err, ShouldBeNil) - - // Use separate resty client with certificates, because we cannot perform cleanup with resty.SetCertificates() - client := resty.New().SetTLSClientConfig(&tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - Certificates: []tls.Certificate{cert}, - }) - - // with client certs but without creds, reads should succeed - resp, err = client.R().Get(secureBaseURL + "/v2/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // with only client certs, writes should fail with insufficient permissions - resp, err = client.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) - - // with client certs and creds, should get expected status code - resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - }) -} - type testLDAPServer struct { server *vldap.Server quitCh chan bool diff --git a/pkg/api/mtls_test.go b/pkg/api/mtls_test.go new file mode 100644 index 00000000..98566914 --- /dev/null +++ b/pkg/api/mtls_test.go @@ -0,0 +1,1860 @@ +package api_test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "net/http" + "os" + "path" + "path/filepath" + "testing" + "time" + + . "github.com/smartystreets/goconvey/convey" + "gopkg.in/resty.v1" + + "zotregistry.dev/zot/v2/pkg/api" + "zotregistry.dev/zot/v2/pkg/api/config" + test "zotregistry.dev/zot/v2/pkg/test/common" + "zotregistry.dev/zot/v2/pkg/test/mocks" + tlsutils "zotregistry.dev/zot/v2/pkg/test/tls" +) + +var ErrUnexpectedError = errors.New("error: unexpected error") + +// setupTestCerts generates CA, server, and client certificates for testing. +// Returns paths to certificate files and PEM data for CA cert. +func setupTestCerts(t *testing.T) ( + string, string, string, string, string, []byte, +) { + t.Helper() + tempDir := t.TempDir() + + // Generate CA certificate (10 years validity, matching gen_certs.sh) + caOpts := &tlsutils.CertificateOptions{ + CommonName: "*", + NotAfter: time.Now().AddDate(10, 0, 0), + } + caCertPEM, caKeyPEM, err := tlsutils.GenerateCACert(caOpts) + if err != nil { + t.Fatalf("Failed to generate CA cert: %v", err) + } + + caCertPath := path.Join(tempDir, "ca.crt") + caKeyPath := path.Join(tempDir, "ca.key") + err = os.WriteFile(caCertPath, caCertPEM, 0o600) + if err != nil { + t.Fatalf("Failed to write CA cert: %v", err) + } + _ = os.WriteFile(caKeyPath, caKeyPEM, 0o600) + + // Generate server certificate + serverCertPath := path.Join(tempDir, "server.cert") + serverKeyPath := path.Join(tempDir, "server.key") + serverOpts := &tlsutils.CertificateOptions{ + Hostname: "127.0.0.1", + CommonName: "*", + OrganizationalUnit: "TestServer", + NotAfter: time.Now().AddDate(10, 0, 0), + } + err = tlsutils.GenerateServerCertToFile(caCertPEM, caKeyPEM, serverCertPath, serverKeyPath, serverOpts) + if err != nil { + t.Fatalf("Failed to generate server cert: %v", err) + } + + // Generate client certificate (10 years validity, matching gen_certs.sh) + clientCertPath := path.Join(tempDir, "client.cert") + clientKeyPath := path.Join(tempDir, "client.key") + clientOpts := &tlsutils.CertificateOptions{ + CommonName: "testclient", + OrganizationalUnit: "TestClient", + NotAfter: time.Now().AddDate(10, 0, 0), + } + err = tlsutils.GenerateClientCertToFile(caCertPEM, caKeyPEM, clientCertPath, clientKeyPath, clientOpts) + if err != nil { + t.Fatalf("Failed to generate client cert: %v", err) + } + + return caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM +} + +// mTLSTestCase defines a test case for mTLS identity extraction. +type mTLSTestCase struct { + name string + clientCertOptions *tlsutils.CertificateOptions + mtlsConfig *config.MTLSConfig + allowedUsers []string // Users allowed in access control + expectedIdentity string // Expected identity extracted from cert + expectedStatus int // Expected HTTP status code + description string // Test description +} + +// getExpectedSubjectDN constructs the expected Subject DN string based on the certificate options. +// This matches the format used by tlsutils for client certificates. +func getExpectedSubjectDN(commonName string) string { + subject := pkix.Name{ + Organization: []string{"Test Client"}, + Country: []string{"US"}, + Province: []string{""}, + Locality: []string{"San Francisco"}, + StreetAddress: []string{""}, + PostalCode: []string{""}, + } + if commonName != "" { + subject.CommonName = commonName + } + + return subject.String() +} + +// runMTLSTest executes a single mTLS test case. +func runMTLSTest(t *testing.T, testCase mTLSTestCase) { + t.Helper() + + tempDir := t.TempDir() + + // Generate CA certificate + caCert, caKey, err := tlsutils.GenerateCACert() + So(err, ShouldBeNil) + caCertPath := path.Join(tempDir, "ca.crt") + err = os.WriteFile(caCertPath, caCert, 0o600) + So(err, ShouldBeNil) + + // Generate server certificate + serverCertPath := path.Join(tempDir, "server.crt") + serverKeyPath := path.Join(tempDir, "server.key") + serverOpts := &tlsutils.CertificateOptions{ + Hostname: "localhost", + } + err = tlsutils.GenerateServerCertToFile(caCert, caKey, serverCertPath, serverKeyPath, serverOpts) + So(err, ShouldBeNil) + + // Generate client certificate + clientCertPath := path.Join(tempDir, "client.crt") + clientKeyPath := path.Join(tempDir, "client.key") + err = tlsutils.GenerateClientCertToFile(caCert, caKey, clientCertPath, clientKeyPath, testCase.clientCertOptions) + So(err, ShouldBeNil) + + // Set up server + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetSecureBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertPath, + Key: serverKeyPath, + CACert: caCertPath, + } + conf.HTTP.Auth = &config.AuthConfig{ + MTLS: testCase.mtlsConfig, + } + + // Set up access control + repoPolicies := make([]config.Policy, 0) + if len(testCase.allowedUsers) > 0 { + repoPolicies = append(repoPolicies, config.Policy{ + Users: testCase.allowedUsers, + Actions: []string{"read", "create"}, + }) + } + + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{ + AnonymousPolicy: make([]string, 0), + Policies: make([]config.Policy, 0), + }, + "test-repo": config.PolicyGroup{ + Policies: repoPolicies, + }, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Set up client + caCertPEM, err := os.ReadFile(caCertPath) + So(err, ShouldBeNil) + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCertPEM) + + clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) + So(err, ShouldBeNil) + + client := resty.New() + client.SetTLSClientConfig(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{clientCert}, + RootCAs: caCertPool, + }) + + // Make request + resp, err := client.R().Get(baseURL + "/v2/test-repo/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, testCase.expectedStatus) +} + +func TestExtractMTLSIdentity(t *testing.T) { + testCases := []mTLSTestCase{ + // Positive tests - authentication should succeed + { + name: "CommonName", + clientCertOptions: &tlsutils.CertificateOptions{ + CommonName: "testuser", + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"CommonName"}, + }, + allowedUsers: []string{"testuser"}, + expectedIdentity: "testuser", + expectedStatus: http.StatusNotFound, // 404 means auth passed + description: "Extract identity from CommonName", + }, + { + name: "Subject", + clientCertOptions: &tlsutils.CertificateOptions{ + CommonName: "testuser", + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"Subject"}, + }, + allowedUsers: []string{getExpectedSubjectDN("testuser")}, + expectedIdentity: getExpectedSubjectDN("testuser"), + expectedStatus: http.StatusNotFound, + description: "Extract identity from Subject DN", + }, + { + name: "EmailSAN", + clientCertOptions: &tlsutils.CertificateOptions{ + EmailAddresses: []string{"testuser@example.com"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"Email"}, + }, + allowedUsers: []string{"testuser@example.com"}, + expectedIdentity: "testuser@example.com", + expectedStatus: http.StatusNotFound, + description: "Extract identity from Email SAN", + }, + { + name: "DNSNameSAN", + clientCertOptions: &tlsutils.CertificateOptions{ + DNSNames: []string{"client.example.com"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"DNSName"}, + }, + allowedUsers: []string{"client.example.com"}, + expectedIdentity: "client.example.com", + expectedStatus: http.StatusNotFound, + description: "Extract identity from DNSName SAN", + }, + { + name: "URISAN", + clientCertOptions: &tlsutils.CertificateOptions{ + URIs: []string{"spiffe://example.org/workload/testuser"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"URI"}, + }, + allowedUsers: []string{"spiffe://example.org/workload/testuser"}, + expectedIdentity: "spiffe://example.org/workload/testuser", + expectedStatus: http.StatusNotFound, + description: "Extract identity from URI SAN", + }, + { + name: "URISANWithRegex", + clientCertOptions: &tlsutils.CertificateOptions{ + URIs: []string{"spiffe://example.org/workload/testuser"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"URI"}, + URISANPattern: "spiffe://example.org/workload/(.*)", + }, + allowedUsers: []string{"testuser"}, + expectedIdentity: "testuser", + expectedStatus: http.StatusNotFound, + description: "Extract identity from URI SAN with regex pattern", + }, + { + name: "FallbackChain", + clientCertOptions: &tlsutils.CertificateOptions{ + CommonName: "testuser", + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"Email", "DNSName", "CommonName"}, + }, + allowedUsers: []string{"testuser"}, + expectedIdentity: "testuser", + expectedStatus: http.StatusNotFound, + description: "Extract identity using fallback chain", + }, + { + name: "CaseInsensitive", + clientCertOptions: &tlsutils.CertificateOptions{ + CommonName: "testuser", + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"commonname"}, // lowercase + }, + allowedUsers: []string{"testuser"}, + expectedIdentity: "testuser", + expectedStatus: http.StatusNotFound, + description: "Extract identity with case-insensitive source name", + }, + { + name: "DNSANIndex1", + clientCertOptions: &tlsutils.CertificateOptions{ + DNSNames: []string{"first.example.com", "second.example.com"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"DNSName"}, + DNSANIndex: 1, // Use second DNS name + }, + allowedUsers: []string{"second.example.com"}, + expectedIdentity: "second.example.com", + expectedStatus: http.StatusNotFound, + description: "Extract identity from DNS SAN with index 1", + }, + { + name: "URISANIndex1", + clientCertOptions: &tlsutils.CertificateOptions{ + URIs: []string{ + "spiffe://example.org/workload/first", + "spiffe://example.org/workload/second", + }, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"URI"}, + URISANIndex: 1, // Use second URI + }, + allowedUsers: []string{"spiffe://example.org/workload/second"}, + expectedIdentity: "spiffe://example.org/workload/second", + expectedStatus: http.StatusNotFound, + description: "Extract identity from URI SAN with index 1", + }, + { + name: "EmailSANIndex1", + clientCertOptions: &tlsutils.CertificateOptions{ + EmailAddresses: []string{"first@example.com", "second@example.com"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"Email"}, + EmailSANIndex: 1, // Use second email + }, + allowedUsers: []string{"second@example.com"}, + expectedIdentity: "second@example.com", + expectedStatus: http.StatusNotFound, + description: "Extract identity from Email SAN with index 1", + }, + // Negative tests - authentication should fail + { + name: "CommonNameNotAllowed", + clientCertOptions: &tlsutils.CertificateOptions{ + CommonName: "testuser", + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"CommonName"}, + }, + allowedUsers: []string{"otheruser"}, // Different user + expectedIdentity: "testuser", + expectedStatus: http.StatusForbidden, // 403 means auth passed but access denied + description: "Authentication succeeds but user not in allowed list", + }, + { + name: "EmailSANNotAllowed", + clientCertOptions: &tlsutils.CertificateOptions{ + EmailAddresses: []string{"testuser@example.com"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"Email"}, + }, + allowedUsers: []string{"other@example.com"}, + expectedIdentity: "testuser@example.com", + expectedStatus: http.StatusForbidden, + description: "Email SAN extracted but user not in allowed list", + }, + { + name: "DNSNameSANNotAllowed", + clientCertOptions: &tlsutils.CertificateOptions{ + DNSNames: []string{"client.example.com"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"DNSName"}, + }, + allowedUsers: []string{"other.example.com"}, + expectedIdentity: "client.example.com", + expectedStatus: http.StatusForbidden, + description: "DNSName SAN extracted but user not in allowed list", + }, + { + name: "URISANNotAllowed", + clientCertOptions: &tlsutils.CertificateOptions{ + URIs: []string{"spiffe://example.org/workload/testuser"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"URI"}, + }, + allowedUsers: []string{"spiffe://example.org/workload/other"}, + expectedIdentity: "spiffe://example.org/workload/testuser", + expectedStatus: http.StatusForbidden, + description: "URI SAN extracted but user not in allowed list", + }, + { + name: "URISANRegexNotAllowed", + clientCertOptions: &tlsutils.CertificateOptions{ + URIs: []string{"spiffe://example.org/workload/testuser"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"URI"}, + URISANPattern: "spiffe://example.org/workload/(.*)", + }, + allowedUsers: []string{"otheruser"}, + expectedIdentity: "testuser", + expectedStatus: http.StatusForbidden, + description: "URI SAN regex extracted but user not in allowed list", + }, + { + name: "NoIdentitySource", + clientCertOptions: &tlsutils.CertificateOptions{ + // No CN, no SANs + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"CommonName"}, + }, + allowedUsers: []string{}, + expectedIdentity: "", + expectedStatus: http.StatusUnauthorized, // 401 means auth failed + description: "No identity found in certificate", + }, + { + name: "DefaultConfigWithoutCN", + clientCertOptions: &tlsutils.CertificateOptions{ + // CommonName intentionally not set - no CN, no SANs + }, + mtlsConfig: nil, // Default behavior (should default to CommonName) + allowedUsers: []string{}, // Not used - authentication fails before authorization is checked + expectedIdentity: "", + expectedStatus: http.StatusUnauthorized, // 401 means auth failed - no CN to extract + description: "Default config (CommonName) with certificate without CN - " + + "authentication fails before authorization", + }, + { + name: "FallbackChainAllFail", + clientCertOptions: &tlsutils.CertificateOptions{ + // No CN, no SANs + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"Email", "DNSName", "CommonName"}, + }, + allowedUsers: []string{}, + expectedIdentity: "", + expectedStatus: http.StatusUnauthorized, + description: "Fallback chain fails when no identity attributes are available", + }, + { + name: "InvalidURISANIndex", + clientCertOptions: &tlsutils.CertificateOptions{ + URIs: []string{"spiffe://example.org/workload/testuser"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"URI"}, + URISANIndex: 5, // Out of range + }, + allowedUsers: []string{}, + expectedIdentity: "", + expectedStatus: http.StatusUnauthorized, + description: "URI SAN index out of range", + }, + { + name: "InvalidDNSANIndex", + clientCertOptions: &tlsutils.CertificateOptions{ + DNSNames: []string{"client.example.com"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"DNSName"}, + DNSANIndex: 5, // Out of range + }, + allowedUsers: []string{}, + expectedIdentity: "", + expectedStatus: http.StatusUnauthorized, + description: "DNS SAN index out of range", + }, + { + name: "InvalidEmailSANIndex", + clientCertOptions: &tlsutils.CertificateOptions{ + EmailAddresses: []string{"testuser@example.com"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"Email"}, + EmailSANIndex: 5, // Out of range + }, + allowedUsers: []string{}, + expectedIdentity: "", + expectedStatus: http.StatusUnauthorized, + description: "Email SAN index out of range", + }, + { + name: "URISANRegexNoMatch", + clientCertOptions: &tlsutils.CertificateOptions{ + URIs: []string{"spiffe://example.org/workload/testuser"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"URI"}, + URISANPattern: "spiffe://other.org/workload/(.*)", // Won't match + }, + allowedUsers: []string{}, + expectedIdentity: "", + expectedStatus: http.StatusUnauthorized, + description: "URI SAN regex pattern doesn't match", + }, + { + name: "NoURISANFound", + clientCertOptions: &tlsutils.CertificateOptions{ + CommonName: "testuser", // Has CN but no URIs + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"URI"}, // Try to extract from URL but cert has no URIs + }, + allowedUsers: []string{}, + expectedIdentity: "", + expectedStatus: http.StatusUnauthorized, + description: "No URI SAN found in certificate when URL is requested", + }, + { + name: "InvalidURISANPattern", + clientCertOptions: &tlsutils.CertificateOptions{ + URIs: []string{"spiffe://example.org/workload/testuser"}, + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"URI"}, + URISANPattern: "[invalid(regex", // Invalid regex pattern + }, + allowedUsers: []string{}, + expectedIdentity: "", + expectedStatus: http.StatusUnauthorized, + description: "Invalid URI SAN regex pattern", + }, + { + name: "UnsupportedIdentitySource", + clientCertOptions: &tlsutils.CertificateOptions{ + CommonName: "testuser", + }, + mtlsConfig: &config.MTLSConfig{ + IdentityAttibutes: []string{"InvalidSource"}, // Unsupported source + }, + allowedUsers: []string{}, + expectedIdentity: "", + expectedStatus: http.StatusUnauthorized, + description: "Unsupported identity source", + }, + } + + Convey("Test mTLS identity extraction", t, func() { + for _, tc := range testCases { + Convey(tc.description+" ("+tc.name+")", func() { + runMTLSTest(t, tc) + }) + } + }) +} + +func TestMTLSAuthentication(t *testing.T) { + // Create temporary directory for certificates + tempDir := t.TempDir() + + // Generate CA certificate + caCert, caKey, err := tlsutils.GenerateCACert() + if err != nil { + panic(err) + } + caCertPath := path.Join(tempDir, "ca.crt") + err = os.WriteFile(caCertPath, caCert, 0o600) + if err != nil { + panic(err) + } + + // Generate server certificate + serverCertPath := path.Join(tempDir, "server.crt") + serverKeyPath := path.Join(tempDir, "server.key") + opts := &tlsutils.CertificateOptions{ + Hostname: "localhost", + } + err = tlsutils.GenerateServerCertToFile(caCert, caKey, serverCertPath, serverKeyPath, opts) + if err != nil { + panic(err) + } + + // Generate valid client certificate for "testuser" user + clientCertPath := path.Join(tempDir, "client.crt") + clientKeyPath := path.Join(tempDir, "client.key") + clientOpts := &tlsutils.CertificateOptions{ + CommonName: "testuser", + } + err = tlsutils.GenerateClientCertToFile(caCert, caKey, clientCertPath, clientKeyPath, clientOpts) + if err != nil { + panic(err) + } + + // Generate self-signed client cert for "testuser" user + selfSignedClientCertPath := path.Join(tempDir, "client-selfsigned.crt") + selfSignedClientKeyPath := path.Join(tempDir, "client-selfsigned.key") + selfSignedOpts := &tlsutils.CertificateOptions{ + CommonName: "testuser", + } + err = tlsutils.GenerateClientSelfSignedCertToFile(selfSignedClientCertPath, selfSignedClientKeyPath, selfSignedOpts) + if err != nil { + panic(err) + } + + // Create htpasswd file with sample "httpuser" + htpasswdPath := test.MakeHtpasswdFileFromString(t, test.GetBcryptCredString("httpuser", "httppass")) + defer os.Remove(htpasswdPath) + + Convey("Test mTLS-only authentication", t, func() { + // Set up server + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetSecureBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertPath, + Key: serverKeyPath, + CACert: caCertPath, + } + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Groups: config.Groups{ + "mtls-users": config.Group{ + Users: []string{"testuser"}, + }, + }, + Repositories: config.Repositories{ + "**": config.PolicyGroup{ // Default restrict all + AnonymousPolicy: make([]string, 0), + Policies: make([]config.Policy, 0), + }, + "test-repo": config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{"testuser"}, + Actions: []string{"read", "create"}, + }, + }, + }, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Test without client certificate - should fail + caCertPEM, err := os.ReadFile(caCertPath) + So(err, ShouldBeNil) + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCertPEM) + + client := resty.New() + client.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS13}) + resp, err := client.R().Get(baseURL + "/v2/test-repo/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // Test with valid client certificate - should succeed + clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) + So(err, ShouldBeNil) + + client = resty.New() + client.SetTLSClientConfig(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{clientCert}, + RootCAs: caCertPool, + }) + + resp, err = client.R().Get(baseURL + "/v2/test-repo/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth + + // Test with self-signed client certificate - should fail + selfSignedClientCert, err := tls.LoadX509KeyPair(selfSignedClientCertPath, selfSignedClientKeyPath) + So(err, ShouldBeNil) + + client = resty.New() + client.SetTLSClientConfig(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{selfSignedClientCert}, + RootCAs: caCertPool, + }) + + resp, err = client.R().Get(baseURL + "/v2/test-selfsigned-repo/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + + Convey("Test mTLS with basic auth and user/group access policies", t, func() { + // Set up server + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetSecureBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertPath, + Key: serverKeyPath, + CACert: caCertPath, + } + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Groups: config.Groups{ + "mtls-users": config.Group{ + Users: []string{"testuser"}, + }, + }, + Repositories: config.Repositories{ + "**": config.PolicyGroup{ // Default restrict all + AnonymousPolicy: make([]string, 0), + Policies: make([]config.Policy, 0), + }, + "group-repo": config.PolicyGroup{ + Policies: []config.Policy{ + { + Groups: []string{"mtls-users"}, + Actions: []string{"read", "create"}, + }, + }, + }, + "test-repo": config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{"testuser"}, + Actions: []string{"read", "create"}, + }, + }, + }, + "htpasswd-repo": config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{"httpuser"}, + Actions: []string{"read", "create"}, + }, + }, + }, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Load server CA certificate + caCertPEM, err := os.ReadFile(caCertPath) + So(err, ShouldBeNil) + + // Load self-signed client certificate + selfSignedClientCert, err := tls.LoadX509KeyPair(selfSignedClientCertPath, selfSignedClientKeyPath) + So(err, ShouldBeNil) + + // Load valid client certificate with CN "testuser" + clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) + So(err, ShouldBeNil) + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCertPEM) + + // Tests without client certificate + client := resty.New() + client.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS13}) + resp, err := client.R().SetBasicAuth("httpuser", "httppass").Get(baseURL + "/v2/htpasswd-repo/tags/list") + // Test without client CA but with htpasswd credentials - should pass because of valid htpasswd credentials + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth + + // Tests with self-signed (== non-acceptable by server) client certificate + client = resty.New() + client.SetTLSClientConfig(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{selfSignedClientCert}, + RootCAs: caCertPool, + }) + + // Test with self-signed client certificate - should still pass because of correct htpasswd auth + resp, err = client.R().SetBasicAuth("httpuser", "httppass").Get(baseURL + "/v2/htpasswd-repo/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth + + // Tests with valid client certificate + client = resty.New() + client.SetTLSClientConfig(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{clientCert}, + RootCAs: caCertPool, + }) + // Tests with valid client cert and creds - should fail with 403 due to no permissions for user from basic auth + // This validates that identity from basic auth has higher priority over mTLS identity + resp, err = client.R().SetBasicAuth("httpuser", "httppass").Get(baseURL + "/v2/test-repo/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // Test with correct auth credentials and different basic auth username from client certificate CN - should success + // This validates that identity from basic auth has higher priority over mTLS identity + resp, err = client.R().SetBasicAuth("httpuser", "httppass").Get(baseURL + "/v2/htpasswd-repo/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth + + // Should have access to test-repo for identity from client-cert + resp, err = client.R().Get(baseURL + "/v2/test-repo/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth + + // Should not have access to other repos for identity from client-cert + resp, err = client.R().Get(baseURL + "/v2/unauthorized-repo/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // Should have access to group-repo through group membership for identity from client-cert + resp, err = client.R().Get(baseURL + "/v2/group-repo/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth + }) +} + +func TestMTLSAuthenticationWithCertificateChain(t *testing.T) { + // Create temporary directory for certificates + tempDir := t.TempDir() + + Convey("Test mTLS with certificate chain - uses leaf certificate identity", t, func() { + // Create certificate chain: Root CA -> Intermediate CA -> Client Certificate + // Generate root CA + rootCACert, rootCAKey, err := tlsutils.GenerateCACert() + So(err, ShouldBeNil) + rootCACertPath := path.Join(tempDir, "root-ca.crt") + err = os.WriteFile(rootCACertPath, rootCACert, 0o600) + So(err, ShouldBeNil) + + // Generate intermediate CA (signed by root CA) + intermediateCAOpts := &tlsutils.CertificateOptions{ + CommonName: "Intermediate CA", + } + intermediateCACert, intermediateCAKeyPEM, err := tlsutils.GenerateIntermediateCACert( + rootCACert, rootCAKey, intermediateCAOpts) + So(err, ShouldBeNil) + + // Generate client certificate with CN signed by intermediate CA + clientWithCNOpts := &tlsutils.CertificateOptions{ + CommonName: "clientuser", + } + clientCertWithCN, clientKeyWithCN, err := tlsutils.GenerateClientCert( + intermediateCACert, intermediateCAKeyPEM, clientWithCNOpts) + So(err, ShouldBeNil) + + // Generate client certificate without CN signed by intermediate CA + clientWithoutCNOpts := &tlsutils.CertificateOptions{ + // No CommonName - empty to test that identity is not taken from intermediate CA + } + clientCertWithoutCN, clientKeyWithoutCNPEM, err := tlsutils.GenerateClientCert( + intermediateCACert, intermediateCAKeyPEM, clientWithoutCNOpts) + So(err, ShouldBeNil) + + // Generate server certificate signed by root CA for this test + serverCertForChainPath := path.Join(tempDir, "server-chain.crt") + serverKeyForChainPath := path.Join(tempDir, "server-chain.key") + serverOpts := &tlsutils.CertificateOptions{ + Hostname: "localhost", + } + err = tlsutils.GenerateServerCertToFile( + rootCACert, rootCAKey, serverCertForChainPath, serverKeyForChainPath, serverOpts) + So(err, ShouldBeNil) + + // Set up server with root CA + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetSecureBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertForChainPath, + Key: serverKeyForChainPath, + CACert: rootCACertPath, // Server trusts root CA + } + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{ + AnonymousPolicy: make([]string, 0), + Policies: make([]config.Policy, 0), + }, + "client-repo": config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{"clientuser"}, + Actions: []string{"read", "create"}, + }, + }, + }, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(rootCACert) + + // Test 1: Client cert with CN in chain - should use client cert CN, not intermediate CA CN + clientCertWithCNPath := path.Join(tempDir, "client-with-cn.crt") + clientKeyWithCNPath := path.Join(tempDir, "client-with-cn.key") + err = os.WriteFile(clientCertWithCNPath, clientCertWithCN, 0o600) + So(err, ShouldBeNil) + err = os.WriteFile(clientKeyWithCNPath, clientKeyWithCN, 0o600) + So(err, ShouldBeNil) + + // Create certificate chain file (client cert + intermediate CA) + chainCertPath := path.Join(tempDir, "client-with-cn-chain.crt") + err = tlsutils.WriteCertificateChainToFile(chainCertPath, clientCertWithCN, intermediateCACert) + So(err, ShouldBeNil) + + // Load certificate chain + clientCertChain, err := tls.LoadX509KeyPair(chainCertPath, clientKeyWithCNPath) + So(err, ShouldBeNil) + + client := resty.New() + client.SetTLSClientConfig(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{clientCertChain}, + RootCAs: caCertPool, + }) + + // Should succeed because client cert has CN "clientuser" which matches policy + resp, err := client.R().Get(baseURL + "/v2/client-repo/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 means auth passed + + // Test 2: Client cert without CN in chain - should fail, not use intermediate CA CN + clientCertWithoutCNPath := path.Join(tempDir, "client-without-cn.crt") + clientKeyWithoutCNPath := path.Join(tempDir, "client-without-cn.key") + err = os.WriteFile(clientCertWithoutCNPath, clientCertWithoutCN, 0o600) + So(err, ShouldBeNil) + err = os.WriteFile(clientKeyWithoutCNPath, clientKeyWithoutCNPEM, 0o600) + So(err, ShouldBeNil) + + // Create certificate chain file (client cert without CN + intermediate CA) + chainCertWithoutCNPath := path.Join(tempDir, "client-without-cn-chain.crt") + err = tlsutils.WriteCertificateChainToFile(chainCertWithoutCNPath, clientCertWithoutCN, intermediateCACert) + So(err, ShouldBeNil) + + // Load certificate chain + clientCertChainWithoutCN, err := tls.LoadX509KeyPair(chainCertWithoutCNPath, clientKeyWithoutCNPath) + So(err, ShouldBeNil) + + client = resty.New() + client.SetTLSClientConfig(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{clientCertChainWithoutCN}, + RootCAs: caCertPool, + }) + + // Should fail because client cert has no CN, even though intermediate CA has CN + resp, err = client.R().Get(baseURL + "/v2/client-repo/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) +} + +func TestMTLSAuthenticationWithExpiredCertificate(t *testing.T) { + // Create temporary directory for certificates + tempDir := t.TempDir() + + Convey("Test mTLS authentication with expired certificate", t, func() { + // Generate CA certificate + caCert, caKey, err := tlsutils.GenerateCACert() + So(err, ShouldBeNil) + caCertPath := path.Join(tempDir, "ca.crt") + err = os.WriteFile(caCertPath, caCert, 0o600) + So(err, ShouldBeNil) + + // Generate server certificate + serverCertPath := path.Join(tempDir, "server.crt") + serverKeyPath := path.Join(tempDir, "server.key") + opts := &tlsutils.CertificateOptions{ + Hostname: "localhost", + } + err = tlsutils.GenerateServerCertToFile(caCert, caKey, serverCertPath, serverKeyPath, opts) + So(err, ShouldBeNil) + + // Generate expired client certificate (NotAfter is in the past) + expiredClientCertPath := path.Join(tempDir, "client-expired.crt") + expiredClientKeyPath := path.Join(tempDir, "client-expired.key") + expiredOpts := &tlsutils.CertificateOptions{ + CommonName: "testuser", + NotBefore: time.Now().Add(-365 * 24 * time.Hour), // 1 year ago + NotAfter: time.Now().Add(-24 * time.Hour), // 1 day ago (expired) + } + err = tlsutils.GenerateClientCertToFile(caCert, caKey, expiredClientCertPath, expiredClientKeyPath, expiredOpts) + So(err, ShouldBeNil) + + // Set up server + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetSecureBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertPath, + Key: serverKeyPath, + CACert: caCertPath, + } + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{ + AnonymousPolicy: make([]string, 0), + Policies: make([]config.Policy, 0), + }, + "test-repo": config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{"testuser"}, + Actions: []string{"read", "create"}, + }, + }, + }, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Set up client with expired certificate + caCertPEM, err := os.ReadFile(caCertPath) + So(err, ShouldBeNil) + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCertPEM) + + expiredClientCert, err := tls.LoadX509KeyPair(expiredClientCertPath, expiredClientKeyPath) + So(err, ShouldBeNil) + + client := resty.New() + client.SetTLSClientConfig(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{expiredClientCert}, + RootCAs: caCertPool, + }) + + // Expired certificate should be rejected at TLS handshake level + // The TLS stack will reject it before it reaches the application layer + _, err = client.R().Get(baseURL + "/v2/test-repo/tags/list") + // Error is expected - TLS handshake fails with expired certificate + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "expired certificate") + }) +} + +func TestMTLSAuthenticationWithUnknownCA(t *testing.T) { + // Create temporary directory for certificates + tempDir := t.TempDir() + + Convey("Test mTLS authentication with certificate signed by unknown CA", t, func() { + // Generate server CA and certificate + serverCACert, serverCAKey, err := tlsutils.GenerateCACert() + So(err, ShouldBeNil) + serverCACertPath := path.Join(tempDir, "server-ca.crt") + err = os.WriteFile(serverCACertPath, serverCACert, 0o600) + So(err, ShouldBeNil) + + serverCertPath := path.Join(tempDir, "server.crt") + serverKeyPath := path.Join(tempDir, "server.key") + opts := &tlsutils.CertificateOptions{ + Hostname: "localhost", + } + err = tlsutils.GenerateServerCertToFile(serverCACert, serverCAKey, serverCertPath, serverKeyPath, opts) + So(err, ShouldBeNil) + + // Generate a different CA (unknown to the server) and client certificate + unknownCACert, unknownCAKey, err := tlsutils.GenerateCACert() + So(err, ShouldBeNil) + + unknownClientCertPath := path.Join(tempDir, "client-unknown-ca.crt") + unknownClientKeyPath := path.Join(tempDir, "client-unknown-ca.key") + clientOpts := &tlsutils.CertificateOptions{ + CommonName: "testuser", + } + err = tlsutils.GenerateClientCertToFile(unknownCACert, unknownCAKey, unknownClientCertPath, + unknownClientKeyPath, clientOpts) + So(err, ShouldBeNil) + + // Set up server with server CA (doesn't know about unknown CA) + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetSecureBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertPath, + Key: serverKeyPath, + CACert: serverCACertPath, // Server only trusts serverCACert, not unknownCACert + } + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{ + AnonymousPolicy: make([]string, 0), + Policies: make([]config.Policy, 0), + }, + "test-repo": config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{"testuser"}, + Actions: []string{"read", "create"}, + }, + }, + }, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Set up client with certificate signed by unknown CA + serverCACertPEM, err := os.ReadFile(serverCACertPath) + So(err, ShouldBeNil) + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(serverCACertPEM) + + unknownClientCert, err := tls.LoadX509KeyPair(unknownClientCertPath, unknownClientKeyPath) + So(err, ShouldBeNil) + + client := resty.New() + client.SetTLSClientConfig(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{unknownClientCert}, + RootCAs: caCertPool, + }) + + // Certificate signed by unknown CA should be rejected at TLS handshake level + // The TLS stack will reject it before it reaches the application layer + _, err = client.R().Get(baseURL + "/v2/test-repo/tags/list") + // Error is expected - TLS handshake fails with unknown certificate authority + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "unknown certificate authority") + }) +} + +func TestMTLSAuthenticationWithMetaDBError(t *testing.T) { + // Create temporary directory for certificates + tempDir := t.TempDir() + + Convey("Test mTLS authentication with MetaDB.SetUserGroups error", t, func() { + // Generate CA certificate + caCert, caKey, err := tlsutils.GenerateCACert() + So(err, ShouldBeNil) + caCertPath := path.Join(tempDir, "ca.crt") + err = os.WriteFile(caCertPath, caCert, 0o600) + So(err, ShouldBeNil) + + // Generate server certificate + serverCertPath := path.Join(tempDir, "server.crt") + serverKeyPath := path.Join(tempDir, "server.key") + opts := &tlsutils.CertificateOptions{ + Hostname: "localhost", + } + err = tlsutils.GenerateServerCertToFile(caCert, caKey, serverCertPath, serverKeyPath, opts) + So(err, ShouldBeNil) + + // Generate valid client certificate for "testuser" user + clientCertPath := path.Join(tempDir, "client.crt") + clientKeyPath := path.Join(tempDir, "client.key") + clientOpts := &tlsutils.CertificateOptions{ + CommonName: "testuser", + } + err = tlsutils.GenerateClientCertToFile(caCert, caKey, clientCertPath, clientKeyPath, clientOpts) + So(err, ShouldBeNil) + + // Set up server + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetSecureBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertPath, + Key: serverKeyPath, + CACert: caCertPath, + } + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Groups: config.Groups{ + "mtls-users": config.Group{ + Users: []string{"testuser"}, + }, + }, + Repositories: config.Repositories{ + "**": config.PolicyGroup{ + AnonymousPolicy: make([]string, 0), + Policies: make([]config.Policy, 0), + }, + "test-repo": config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{"testuser"}, + Actions: []string{"read", "create"}, + }, + }, + }, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Set up client with valid certificate + caCertPEM, err := os.ReadFile(caCertPath) + So(err, ShouldBeNil) + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCertPEM) + + clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) + So(err, ShouldBeNil) + + client := resty.New() + client.SetTLSClientConfig(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{clientCert}, + RootCAs: caCertPool, + }) + + // Mock MetaDB to return error on SetUserGroups + ctlr.MetaDB = mocks.MetaDBMock{ + SetUserGroupsFn: func(ctx context.Context, groups []string) error { + return ErrUnexpectedError + }, + } + + // Should return 500 Internal Server Error due to MetaDB error + resp, err := client.R().Get(baseURL + "/v2/test-repo/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + }) +} + +func TestMutualTLSAuthWithUserPermissions(t *testing.T) { + Convey("Make a new controller", t, func() { + caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t) + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCertPEM) + + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + secureBaseURL := test.GetSecureBaseURL(port) + + resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) + + defer func() { resty.SetTLSClientConfig(nil) }() + + conf := config.New() + conf.HTTP.Port = port + + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertPath, + Key: serverKeyPath, + CACert: caCertPath, + } + + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + test.AuthorizationAllRepos: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{"testclient"}, + Actions: []string{"read"}, + }, + }, + }, + }, + } + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + + defer cm.StopServer() + + resp, err := resty.R().Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + repoPolicy := conf.HTTP.AccessControl.Repositories[test.AuthorizationAllRepos] + + // setup TLS mutual auth + cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) + So(err, ShouldBeNil) + + // Use separate resty client with certificates, because we cannot perform cleanup with resty.SetCertificates() + client := resty.New().SetTLSClientConfig(&tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + }) + + // with client certs but without creds, should succeed + resp, err = client.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = client.R().Get(secureBaseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // with creds, should get expected status code + resp, _ = client.R().Get(secureBaseURL) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + // reading a repo should not get 403 + resp, err = client.R().Get(secureBaseURL + "/v2/repo/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + // without creds, writes should fail + resp, err = client.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // empty default authorization and give user the permission to create + repoPolicy.Policies[0].Actions = append(repoPolicy.Policies[0].Actions, "create") + conf.HTTP.AccessControl.Repositories[test.AuthorizationAllRepos] = repoPolicy + resp, err = client.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + }) +} + +func TestTLSMutualAuth(t *testing.T) { + Convey("Make a new controller", t, func() { + caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t) + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCertPEM) + + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + secureBaseURL := test.GetSecureBaseURL(port) + + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertPath, + Key: serverKeyPath, + CACert: caCertPath, + } + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + + defer cm.StopServer() + + // access without any certificate settings + client := resty.New() + + // accessing insecure HTTP site should fail + resp, err := client.R().Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // without client certs and creds, should get certificate verification error + _, err = client.R().Get(secureBaseURL) + So(err, ShouldNotBeNil) + + // without client certs should fail auth + _, err = client.R().Get(secureBaseURL + "/v2/") + So(err, ShouldNotBeNil) + + // Use resty client with certificates, + client = resty.New().SetTLSClientConfig(&tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + }) + + // without client certs should fail auth + resp, err = client.R().Get(secureBaseURL) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + // without client certs should fail auth + resp, _ = client.R().Get(secureBaseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + username, seedUser := test.GenerateRandomString() + password, seedPass := test.GenerateRandomString() + ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password") + + resp, err = client.R().SetBasicAuth(username, password).Get(secureBaseURL) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + // with only creds, should get 401 because basic auth is disabled + // (Authorization header should be rejected when the auth method is disabled, regardless of mTLS) + resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // setup TLS mutual auth + cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) + So(err, ShouldBeNil) + + client = resty.New().SetTLSClientConfig(&tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + }) + + // with client certs but without creds, should succeed + resp, err = client.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + // with client certs and creds, should get 401 because basic auth is disabled + // (Authorization header should be rejected when the auth method is disabled, regardless of mTLS) + resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) +} + +func TestTLSMutualAuthAllowReadAccess(t *testing.T) { + Convey("Make a new controller", t, func() { + caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t) + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCertPEM) + + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + secureBaseURL := test.GetSecureBaseURL(port) + + // Use resty client with certificates, + client := resty.New().SetTLSClientConfig(&tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + }) + + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertPath, + Key: serverKeyPath, + CACert: caCertPath, + } + + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + test.AuthorizationAllRepos: config.PolicyGroup{ + AnonymousPolicy: []string{"read"}, + }, + }, + } + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + + defer cm.StopServer() + + // accessing insecure HTTP site should fail + resp, err := client.R().Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // without client certs and creds, reads are allowed + resp, err = client.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + username, seedUser := test.GenerateRandomString() + password, seedPass := test.GenerateRandomString() + + ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password") + // with creds but without certs, reads are not allowed as server does not use basic auth + // and basic auth headers are expected to contain valid credentials + resp, err = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // without creds, writes should fail + resp, err = client.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // setup TLS mutual auth + cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) + So(err, ShouldBeNil) + + // Use separate resty client with certificates, because we cannot perform cleanup with resty.SetCertificates() + client = resty.New().SetTLSClientConfig(&tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + }) + + // with client certs but without creds, should succeed + resp, _ = client.R().Get(secureBaseURL) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + // with client certs but without creds, should succeed + resp, err = client.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // with client certs and creds, reads are not allowed as server does not use basic auth + // and basic auth headers are expected to contain valid credentials + resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + // with client certs, reads are not allowed as server does not use basic auth + resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) +} + +func TestTLSMutualAndBasicAuth(t *testing.T) { + Convey("Make a new controller", t, func() { + caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t) + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCertPEM) + + username, seedUser := test.GenerateRandomString() + password, seedPass := test.GenerateRandomString() + + htpasswdPath := test.MakeHtpasswdFileFromString(t, test.GetBcryptCredString(username, password)) + + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + secureBaseURL := test.GetSecureBaseURL(port) + + resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) + + defer func() { resty.SetTLSClientConfig(nil) }() + + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertPath, + Key: serverKeyPath, + CACert: caCertPath, + } + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password") + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + + defer cm.StopServer() + + // accessing insecure HTTP site should fail + resp, err := resty.R().Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // without client certs and creds, should fail + _, err = resty.R().Get(secureBaseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // with creds but without certs, should succeed + _, err = resty.R().SetBasicAuth(username, password).Get(secureBaseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // setup TLS mutual auth + cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) + So(err, ShouldBeNil) + + // Use separate resty client with certificates, because we cannot perform cleanup with resty.SetCertificates() + client := resty.New().SetTLSClientConfig(&tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + }) + + // with client certs but without creds, succeed because mTLS is used for auth when no auth headers provided + resp, err = client.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // with client certs and creds, should get expected status code + resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) +} + +func TestTLSMutualAndBasicAuthAllowReadAccess(t *testing.T) { + Convey("Make a new controller", t, func() { + caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t) + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCertPEM) + + username, seedUser := test.GenerateRandomString() + password, seedPass := test.GenerateRandomString() + + htpasswdPath := test.MakeHtpasswdFileFromString(t, test.GetBcryptCredString(username, password)) + + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + secureBaseURL := test.GetSecureBaseURL(port) + + resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) + + defer func() { resty.SetTLSClientConfig(nil) }() + + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertPath, + Key: serverKeyPath, + CACert: caCertPath, + } + + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + test.AuthorizationAllRepos: config.PolicyGroup{ + AnonymousPolicy: []string{"read"}, + }, + }, + } + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password") + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + + defer cm.StopServer() + + // accessing insecure HTTP site should fail + resp, err := resty.R().Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // without client certs and creds, should fail + _, err = resty.R().Get(secureBaseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // with creds but without certs, should succeed + _, err = resty.R().SetBasicAuth(username, password).Get(secureBaseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // setup TLS mutual auth + cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) + So(err, ShouldBeNil) + + // Use separate resty client with certificates, because we cannot perform cleanup with resty.SetCertificates() + client := resty.New().SetTLSClientConfig(&tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + }) + + // with client certs but without creds, reads should succeed + resp, err = client.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // with only client certs, writes should fail with insufficient permissions + resp, err = client.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // with client certs and creds, should get expected status code + resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) +} + +func TestTSLFailedReadingOfCACert(t *testing.T) { + Convey("no permissions", t, func() { + caCertPath, serverCertPath, serverKeyPath, _, _, _ := setupTestCerts(t) + port := test.GetFreePort() + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertPath, + Key: serverKeyPath, + CACert: caCertPath, + } + + err := os.Chmod(caCertPath, 0o000) + defer func() { + err := os.Chmod(caCertPath, 0o644) + So(err, ShouldBeNil) + }() + So(err, ShouldBeNil) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + err = ctlr.Init() + So(err, ShouldBeNil) + + errChan := make(chan error, 1) + + go func() { + err = ctlr.Run() + errChan <- err + }() + + testTimeout := false + + select { + case err := <-errChan: + So(err, ShouldNotBeNil) + case <-ctx.Done(): + testTimeout = true + + cancel() + } + + So(testTimeout, ShouldBeFalse) + }) + + Convey("empty CACert", t, func() { + badCACert := filepath.Join(t.TempDir(), "badCACert") + err := os.WriteFile(badCACert, []byte(""), 0o600) + So(err, ShouldBeNil) + + _, serverCertPath, serverKeyPath, _, _, _ := setupTestCerts(t) + port := test.GetFreePort() + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: serverCertPath, + Key: serverKeyPath, + CACert: badCACert, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + err = ctlr.Init() + So(err, ShouldBeNil) + + errChan := make(chan error, 1) + + go func() { + err = ctlr.Run() + errChan <- err + }() + + testTimeout := false + + select { + case err := <-errChan: + So(err, ShouldNotBeNil) + case <-ctx.Done(): + testTimeout = true + + cancel() + } + + So(testTimeout, ShouldBeFalse) + }) +} diff --git a/pkg/cli/server/root.go b/pkg/cli/server/root.go index 8c2c9e42..1f24afce 100644 --- a/pkg/cli/server/root.go +++ b/pkg/cli/server/root.go @@ -567,6 +567,10 @@ func validateConfiguration(config *config.Config, logger zlog.Logger) error { return err } + if err := validateMTLS(config, logger); err != nil { + return err + } + if err := validateOpenIDConfig(config, logger); err != nil { return err } @@ -1268,6 +1272,100 @@ func validateLDAP(config *config.Config, logger zlog.Logger) error { return nil } +// validateMTLS checks if the authentication settings for MTLS are valid. +func validateMTLS(config *config.Config, logger zlog.Logger) error { + mtlsConfig := config.CopyAuthConfig().GetMTLSConfig() + if mtlsConfig == nil { + return nil + } + + // If mTLS config is present, TLS must be properly configured + if !config.IsMTLSAuthEnabled() { + msg := "mTLS configuration requires TLS to be enabled with CA certificate" + logger.Error().Msg(msg) + + return fmt.Errorf("%w: %s", zerr.ErrBadConfig, msg) + } + + if len(mtlsConfig.IdentityAttibutes) > 0 { + validIdentityAttributes := []string{ + "CommonName", "CN", "Subject", "DN", "Email", "rfc822name", "URI", "URL", "DNSName", "DNS", + } + + var unrecognizedIdentityAttributes []string + + for _, source := range mtlsConfig.IdentityAttibutes { + idx := slices.IndexFunc(validIdentityAttributes, + func(s string) bool { + return strings.EqualFold(strings.TrimSpace(source), strings.TrimSpace(s)) + }, + ) + + if idx < 0 { + unrecognizedIdentityAttributes = append(unrecognizedIdentityAttributes, source) + } + } + + if len(unrecognizedIdentityAttributes) > 0 { + logger.Error().Strs("identityAttributes", unrecognizedIdentityAttributes).Msg("unsupported identityAttributes") + + return fmt.Errorf("%w: %s", zerr.ErrUnsupportedIdentityAttribute, strings.Join(unrecognizedIdentityAttributes, ",")) + } + } + + idx := slices.IndexFunc(mtlsConfig.IdentityAttibutes, + func(s string) bool { + return strings.ToLower(strings.TrimSpace(s)) == "uri" || strings.ToLower(strings.TrimSpace(s)) == "url" + }, + ) + + useSan := idx >= 0 + + if mtlsConfig.DNSANIndex != 0 && !useSan { + logger.Error().Int("dnsSanIndex", mtlsConfig.DNSANIndex).Strs("identityAttributes", mtlsConfig.IdentityAttibutes). + Msg("dnsSanIndex is only supported for URI/URL MTLS identity attribute") + + return fmt.Errorf("%w: dnsSanIndex is only supported for URI/URL MTLS identity attribute", + zerr.ErrBadConfig) + } + + if mtlsConfig.EmailSANIndex != 0 && !useSan { + logger.Error().Int("emailSanIndex", mtlsConfig.EmailSANIndex). + Strs("identityAttributes", mtlsConfig.IdentityAttibutes). + Msg("emailSanIndex is only supported for URI/URL MTLS identity attribute") + + return fmt.Errorf("%w: emailSanIndex is only supported for URI/URL MTLS identity attribute", + zerr.ErrBadConfig) + } + + if mtlsConfig.URISANIndex != 0 && !useSan { + logger.Error().Int("uriSanIndex", mtlsConfig.URISANIndex).Strs("identityAttributes", mtlsConfig.IdentityAttibutes). + Msg("uriSanIndex is only supported for URI/URL MTLS identity attribute") + + return fmt.Errorf("%w: uriSanIndex is only supported for URI/URL MTLS identity attribute", + zerr.ErrBadConfig) + } + + if mtlsConfig.URISANPattern != "" { + if !useSan { + logger.Error().Str("uriSanPattern", mtlsConfig.URISANPattern). + Strs("identityAttributes", mtlsConfig.IdentityAttibutes). + Msg("uriSanPattern is only supported for URI/URL MTLS identity attribute") + + return fmt.Errorf("%w: uriSanPattern is only supported for URI/URL MTLS identity attribute", + zerr.ErrBadConfig) + } + + if _, err := regexp.Compile(mtlsConfig.URISANPattern); err != nil { + logger.Error().Str("uriSanPattern", mtlsConfig.URISANPattern).Msg("invalid regex pattern") + + return fmt.Errorf("%w: %s", zerr.ErrInvalidURISANPattern, mtlsConfig.URISANPattern) + } + } + + return nil +} + func validateHTTP(config *config.Config, logger zlog.Logger) error { port := config.GetHTTPPort() if port != "" { diff --git a/pkg/cli/server/root_test.go b/pkg/cli/server/root_test.go index e5b34ca5..22250a75 100644 --- a/pkg/cli/server/root_test.go +++ b/pkg/cli/server/root_test.go @@ -1769,6 +1769,372 @@ storage: So(err, ShouldNotBeNil) So(err.Error(), ShouldContainSubstring, "invalid server config") }) + + Convey("Test verify mTLS config validation", t, func(c C) { + Convey("Test valid mTLS config with CommonName", func() { + content := `{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "tls": { + "cert": "test/data/server.cert", + "key": "test/data/server.key", + "cacert": "test/data/ca.crt" + }, + "auth": { + "mtls": { + "identityAttributes": ["CommonName"] + } + } + }, + "log": { + "level": "debug" + } + }` + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + + os.Args = []string{"cli_test", "verify", tmpfile} + err := cli.NewServerRootCmd().Execute() + So(err, ShouldBeNil) + }) + + Convey("Test valid mTLS config with URI and pattern", func() { + content := `{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "tls": { + "cert": "test/data/server.cert", + "key": "test/data/server.key", + "cacert": "test/data/ca.crt" + }, + "auth": { + "mtls": { + "identityAttributes": ["URI", "CommonName"], + "uriSanPattern": "spiffe://example.org/workload/(.*)" + } + } + }, + "log": { + "level": "debug" + } + }` + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + + os.Args = []string{"cli_test", "verify", tmpfile} + err := cli.NewServerRootCmd().Execute() + So(err, ShouldBeNil) + }) + + Convey("Test valid mTLS config with all valid identity attributes", func() { + content := `{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "tls": { + "cert": "test/data/server.cert", + "key": "test/data/server.key", + "cacert": "test/data/ca.crt" + }, + "auth": { + "mtls": { + "identityAttributes": ["CommonName", "CN", "Subject", "DN", "Email", + "rfc822name", "URI", "URL", "DNSName", "DNS"] + } + } + }, + "log": { + "level": "debug" + } + }` + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + + os.Args = []string{"cli_test", "verify", tmpfile} + err := cli.NewServerRootCmd().Execute() + So(err, ShouldBeNil) + }) + + Convey("Test invalid identity attribute", func() { + content := `{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "tls": { + "cert": "test/data/server.cert", + "key": "test/data/server.key", + "cacert": "test/data/ca.crt" + }, + "auth": { + "mtls": { + "identityAttributes": ["InvalidAttribute"] + } + } + }, + "log": { + "level": "debug" + } + }` + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + + os.Args = []string{"cli_test", "verify", tmpfile} + err := cli.NewServerRootCmd().Execute() + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "unsupported identity attribute") + So(err.Error(), ShouldContainSubstring, "InvalidAttribute") + }) + + Convey("Test DNSANIndex without URI/URL identity attribute", func() { + content := `{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "tls": { + "cert": "test/data/server.cert", + "key": "test/data/server.key", + "cacert": "test/data/ca.crt" + }, + "auth": { + "mtls": { + "identityAttributes": ["CommonName"], + "dnsSanIndex": 1 + } + } + }, + "log": { + "level": "debug" + } + }` + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + + os.Args = []string{"cli_test", "verify", tmpfile} + err := cli.NewServerRootCmd().Execute() + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "dnsSanIndex is only supported for URI/URL MTLS identity attribute") + }) + + Convey("Test EmailSANIndex without URI/URL identity attribute", func() { + content := `{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "tls": { + "cert": "test/data/server.cert", + "key": "test/data/server.key", + "cacert": "test/data/ca.crt" + }, + "auth": { + "mtls": { + "identityAttributes": ["CommonName"], + "emailSanIndex": 1 + } + } + }, + "log": { + "level": "debug" + } + }` + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + + os.Args = []string{"cli_test", "verify", tmpfile} + err := cli.NewServerRootCmd().Execute() + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "emailSanIndex is only supported for URI/URL MTLS identity attribute") + }) + + Convey("Test URISANIndex without URI/URL identity attribute", func() { + content := `{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "tls": { + "cert": "test/data/server.cert", + "key": "test/data/server.key", + "cacert": "test/data/ca.crt" + }, + "auth": { + "mtls": { + "identityAttributes": ["CommonName"], + "uriSanIndex": 1 + } + } + }, + "log": { + "level": "debug" + } + }` + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + + os.Args = []string{"cli_test", "verify", tmpfile} + err := cli.NewServerRootCmd().Execute() + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "uriSanIndex is only supported for URI/URL MTLS identity attribute") + }) + + Convey("Test URISANPattern without URI/URL identity attribute", func() { + content := `{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "tls": { + "cert": "test/data/server.cert", + "key": "test/data/server.key", + "cacert": "test/data/ca.crt" + }, + "auth": { + "mtls": { + "identityAttributes": ["CommonName"], + "uriSanPattern": "spiffe://example.org/workload/(.*)" + } + } + }, + "log": { + "level": "debug" + } + }` + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + + os.Args = []string{"cli_test", "verify", tmpfile} + err := cli.NewServerRootCmd().Execute() + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "uriSanPattern is only supported for URI/URL MTLS identity attribute") + }) + + Convey("Test invalid regex pattern for URISANPattern", func() { + content := `{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "tls": { + "cert": "test/data/server.cert", + "key": "test/data/server.key", + "cacert": "test/data/ca.crt" + }, + "auth": { + "mtls": { + "identityAttributes": ["URI"], + "uriSanPattern": "[invalid(regex" + } + } + }, + "log": { + "level": "debug" + } + }` + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + + os.Args = []string{"cli_test", "verify", tmpfile} + err := cli.NewServerRootCmd().Execute() + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "invalid URI SAN pattern") + }) + + Convey("Test valid mTLS config with URL identity attribute", func() { + content := `{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "tls": { + "cert": "test/data/server.cert", + "key": "test/data/server.key", + "cacert": "test/data/ca.crt" + }, + "auth": { + "mtls": { + "identityAttributes": ["URL"], + "uriSanPattern": "spiffe://example.org/workload/(.*)", + "uriSanIndex": 0 + } + } + }, + "log": { + "level": "debug" + } + }` + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + + os.Args = []string{"cli_test", "verify", tmpfile} + err := cli.NewServerRootCmd().Execute() + So(err, ShouldBeNil) + }) + + Convey("Test mTLS config without TLS (should fail - mTLS requires TLS)", func() { + content := `{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "auth": { + "mtls": { + "identityAttributes": ["CommonName"] + } + } + }, + "log": { + "level": "debug" + } + }` + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + + os.Args = []string{"cli_test", "verify", tmpfile} + err := cli.NewServerRootCmd().Execute() + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "mTLS configuration requires TLS to be enabled with CA certificate") + }) + }) } func TestApiKeyConfig(t *testing.T) { diff --git a/pkg/test/tls/tls.go b/pkg/test/tls/tls.go index 5c2b1d21..c927f223 100644 --- a/pkg/test/tls/tls.go +++ b/pkg/test/tls/tls.go @@ -12,6 +12,7 @@ import ( "errors" "fmt" "net" + "net/url" "os" "time" ) @@ -67,6 +68,10 @@ type CertificateOptions struct { // If nil, no email addresses will be included. EmailAddresses []string + // URIs contains the URIs for the Subject Alternative Name extension. + // If nil, no URIs will be included. + URIs []string + // Hostname is the hostname or IP address for server certificates. // For server certificates, this is required and will be added to DNSNames or IPAddresses // based on whether it's a valid IP address or a DNS name. @@ -404,6 +409,27 @@ func applyOptions(template *x509.Certificate, opts *CertificateOptions, certType template.EmailAddresses = opts.EmailAddresses } + // Apply URIs + if opts.URIs != nil { + templateURIs := make([]*url.URL, 0, len(opts.URIs)) + for _, uriStr := range opts.URIs { + uri, err := url.Parse(uriStr) + if err != nil { + // Skip invalid URIs - could log error in production + continue + } + + // Validate that the URI has a valid scheme (url.Parse accepts URIs without schemes) + if uri.Scheme == "" { + // Skip URIs without a scheme - could log error in production + continue + } + + templateURIs = append(templateURIs, uri) + } + template.URIs = templateURIs + } + // Apply CommonName - if provided, override the default; otherwise keep default from initializeTemplate if opts.CommonName != "" { template.Subject.CommonName = opts.CommonName diff --git a/pkg/test/tls/tls_test.go b/pkg/test/tls/tls_test.go index 613bcdcc..ff0afc45 100644 --- a/pkg/test/tls/tls_test.go +++ b/pkg/test/tls/tls_test.go @@ -240,6 +240,30 @@ func TestApplyOptionsCoverage(t *testing.T) { So(cert.EmailAddresses, ShouldContain, "admin@example.com") }) + Convey("Test with URIs including invalid URI", func() { + // Mix of valid and invalid URIs - invalid ones should be skipped + customURIs := []string{ + "spiffe://example.org/workload/test", + "not a valid uri", // Invalid URI - should be skipped + "https://example.com", + "://invalid", // Invalid URI - should be skipped + } + opts := &tls.CertificateOptions{ + Hostname: "localhost", + URIs: customURIs, + } + certPEM, _, err := tls.GenerateServerCert(caCertPEM, caKeyPEM, opts) + So(err, ShouldBeNil) + + certBlock, _ := pem.Decode(certPEM) + cert, err := x509.ParseCertificate(certBlock.Bytes) + So(err, ShouldBeNil) + // Should only contain valid URIs (2 out of 4) + So(len(cert.URIs), ShouldEqual, 2) + So(cert.URIs[0].String(), ShouldEqual, "spiffe://example.org/workload/test") + So(cert.URIs[1].String(), ShouldEqual, "https://example.com") + }) + Convey("Test with all options combined", func() { customNotBefore := time.Now().Add(-12 * time.Hour) customNotAfter := time.Now().Add(365 * 24 * time.Hour)