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)