diff --git a/docs/LICENSE_OF_DEPENDENCIES.md b/docs/LICENSE_OF_DEPENDENCIES.md index 8b0882d14..f57d14156 100644 --- a/docs/LICENSE_OF_DEPENDENCIES.md +++ b/docs/LICENSE_OF_DEPENDENCIES.md @@ -317,6 +317,7 @@ following works: - github.com/opentracing/opentracing-go [Apache License 2.0](https://github.com/opentracing/opentracing-go/blob/master/LICENSE) - github.com/p4lang/p4runtime [Apache License 2.0](https://github.com/p4lang/p4runtime/blob/main/LICENSE) - github.com/paulmach/orb [MIT License](https://github.com/paulmach/orb/blob/master/LICENSE.md) +- github.com/pavlo-v-chernykh/keystore-go [MIT License](https://github.com/pavlo-v-chernykh/keystore-go/blob/main/LICENSE) - github.com/pborman/ansi [BSD 3-Clause "New" or "Revised" License](https://github.com/pborman/ansi/blob/master/LICENSE) - github.com/pcolladosoto/goslurm [MIT License](https://github.com/pcolladosoto/goslurm/blob/main/LICENSE) - github.com/peterbourgon/unixtransport [Apache License 2.0](https://github.com/peterbourgon/unixtransport/blob/main/LICENSE) @@ -471,6 +472,7 @@ following works: - sigs.k8s.io/json [Apache License 2.0](https://github.com/kubernetes/client-go/blob/master/LICENSE) - sigs.k8s.io/structured-merge-diff [Apache License 2.0](https://github.com/kubernetes/client-go/blob/master/LICENSE) - sigs.k8s.io/yaml [Apache License 2.0](https://github.com/kubernetes/client-go/blob/master/LICENSE) +- software.sslmate.com/src/go-pkcs12 [BSD 3-Clause "New" or "Revised" License](https://github.com/SSLMate/go-pkcs12/blob/master/LICENSE) ## Telegraf used and modified code from these projects diff --git a/go.mod b/go.mod index 78751b4b5..453309d3d 100644 --- a/go.mod +++ b/go.mod @@ -161,6 +161,7 @@ require ( github.com/openzipkin-contrib/zipkin-go-opentracing v0.5.0 github.com/openzipkin/zipkin-go v0.4.3 github.com/p4lang/p4runtime v1.4.0 + github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 github.com/pborman/ansi v1.0.0 github.com/pcolladosoto/goslurm v0.1.0 github.com/peterbourgon/unixtransport v0.0.4 @@ -240,6 +241,7 @@ require ( k8s.io/client-go v0.32.1 layeh.com/radius v0.0.0-20221205141417-e7fbddd11d68 modernc.org/sqlite v1.34.1 + software.sslmate.com/src/go-pkcs12 v0.5.0 ) require ( diff --git a/go.sum b/go.sum index 09a689b3d..568852d51 100644 --- a/go.sum +++ b/go.sum @@ -2035,6 +2035,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTK github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 h1:2nosf3P75OZv2/ZO/9Px5ZgZ5gbKrzA3joN1QMfOGMQ= +github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0/go.mod h1:lAVhWwbNaveeJmxrxuSTxMgKpF6DjnuVpn6T8WiBwYQ= github.com/pborman/ansi v1.0.0 h1:OqjHMhvlSuCCV5JT07yqPuJPQzQl+WXsiZ14gZsqOrQ= github.com/pborman/ansi v1.0.0/go.mod h1:SgWzwMAx1X/Ez7i90VqF8LRiQtx52pWDiQP+x3iGnzw= github.com/pborman/getopt v0.0.0-20190409184431-ee0cd42419d3/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= @@ -3473,6 +3475,8 @@ sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aN sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= +software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= storj.io/common v0.0.0-20240812101423-26b53789c348 h1:Urs3fX+1Fyb+CFKGw0mCJV3MPR499WM+Vs6osw4Rqtk= storj.io/common v0.0.0-20240812101423-26b53789c348/go.mod h1:XMpwKxc04HCBl4H5IFCGv1ca5Dm0tvH4NL7Jx+JhxuA= storj.io/drpc v0.0.35-0.20240709171858-0075ac871661 h1:hLvEV2RMTscX3JHPd+LSQCeTt8i1Q0Yt7U2EdfyMnaQ= diff --git a/plugins/inputs/x509_cert/README.md b/plugins/inputs/x509_cert/README.md index 1c37f4be4..f5a9404d4 100644 --- a/plugins/inputs/x509_cert/README.md +++ b/plugins/inputs/x509_cert/README.md @@ -25,7 +25,9 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details. sources = ["tcp://example.org:443", "https://influxdata.com:443", "smtp://mail.localhost:25", "udp://127.0.0.1:4433", "/etc/ssl/certs/ssl-cert-snakeoil.pem", - "/etc/mycerts/*.mydomain.org.pem", "file:///path/to/*.pem"] + "/etc/mycerts/*.mydomain.org.pem", "file:///path/to/*.pem", + "jks:///etc/mycerts/keystore.jks", + "pkcs12:///etc/mycerts/keystore.p12"] ## Timeout for SSL connection # timeout = "5s" @@ -42,6 +44,9 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details. ## Pad certificate serial number with zeroes to 128-bits. # pad_serial_with_zeroes = false + ## Password to be used with PKCS#12 or JKS files + # password = "" + ## Optional TLS Config # tls_ca = "/etc/telegraf/ca.pem" # tls_cert = "/etc/telegraf/cert.pem" diff --git a/plugins/inputs/x509_cert/java_key_store.go b/plugins/inputs/x509_cert/java_key_store.go new file mode 100644 index 000000000..7bd3cb2b8 --- /dev/null +++ b/plugins/inputs/x509_cert/java_key_store.go @@ -0,0 +1,101 @@ +package x509_cert + +import ( + "crypto/x509" + "fmt" + "os" + "path/filepath" + + "github.com/pavlo-v-chernykh/keystore-go/v4" + "software.sslmate.com/src/go-pkcs12" +) + +func normalizePath(path string) string { + normalized := filepath.ToSlash(path) + + // Removing leading slash in Windows path containing a drive-letter + // like "file:///C:/Windows/..." + normalized = reDriveLetter.ReplaceAllString(normalized, "$1") + + return filepath.FromSlash(normalized) +} + +func (c *X509Cert) processPKCS12(path string) ([]*x509.Certificate, error) { + data, err := os.ReadFile(normalizePath(path)) + if err != nil { + return nil, fmt.Errorf("failed to read PKCS#12 file: %w", err) + } + + // Get the password string from config.Secret + password, err := c.Password.Get() + if err != nil { + return nil, fmt.Errorf("failed to get password: %w", err) + } + passwordStr := password.String() + password.Destroy() + + _, cert, caCerts, err := pkcs12.DecodeChain(data, passwordStr) + if err != nil { + return nil, fmt.Errorf("failed to decode PKCS#12 keystore: %w", err) + } + + // Ensure Root CA pool exists + if c.tlsCfg.RootCAs == nil { + c.tlsCfg.RootCAs = x509.NewCertPool() + } + + // Add CA certificates to RootCAs + for _, caCert := range caCerts { + c.tlsCfg.RootCAs.AddCert(caCert) + } + + return append([]*x509.Certificate{cert}, caCerts...), nil +} + +func (c *X509Cert) processJKS(path string) ([]*x509.Certificate, error) { + file, err := os.Open(normalizePath(path)) + if err != nil { + return nil, fmt.Errorf("failed to open JKS file: %w", err) + } + defer file.Close() + + // Get the password string from config.Secret + password, err := c.Password.Get() + if err != nil { + return nil, fmt.Errorf("failed to get password: %w", err) + } + defer password.Destroy() + + ks := keystore.New() + if err := ks.Load(file, password.Bytes()); err != nil { + return nil, fmt.Errorf("failed to decode JKS: %w", err) + } + + // Ensure Root CA pool exists + if c.tlsCfg.RootCAs == nil { + c.tlsCfg.RootCAs = x509.NewCertPool() + } + + certs := make([]*x509.Certificate, 0, len(ks.Aliases())) + + for _, alias := range ks.Aliases() { + // Check for both trusted certificates and private key entries + if entry, err := ks.GetTrustedCertificateEntry(alias); err == nil { + cert, err := x509.ParseCertificate(entry.Certificate.Content) + if err == nil { + c.tlsCfg.RootCAs.AddCert(cert) + certs = append(certs, cert) + } + } else if entry, err := ks.GetPrivateKeyEntry(alias, password.Bytes()); err == nil { + for _, certData := range entry.CertificateChain { + cert, err := x509.ParseCertificate(certData.Content) + if err == nil { + c.tlsCfg.RootCAs.AddCert(cert) + certs = append(certs, cert) + } + } + } + } + + return certs, nil +} diff --git a/plugins/inputs/x509_cert/java_key_store_test.go b/plugins/inputs/x509_cert/java_key_store_test.go new file mode 100644 index 000000000..559160ccd --- /dev/null +++ b/plugins/inputs/x509_cert/java_key_store_test.go @@ -0,0 +1,201 @@ +package x509_cert + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/pavlo-v-chernykh/keystore-go/v4" + "github.com/stretchr/testify/require" + "software.sslmate.com/src/go-pkcs12" + + "github.com/influxdata/telegraf/config" + "github.com/influxdata/telegraf/testutil" +) + +type selfSignedCert struct { + certPEM []byte + keyPEM []byte + certDER []byte +} + +// generateTestKeystores creates temporary JKS & PKCS#12 keystores for testing +func generateTestKeystores(t *testing.T) (pkcs12Path, jksPath string) { + t.Helper() + + // Generate a test certificate + selfSigned := generateselfSignedCert(t) + + pkcs12Path = createTestPKCS12(t, selfSigned.certPEM, selfSigned.keyPEM) + jksPath = createTestJKS(t, selfSigned.certDER) + + return pkcs12Path, jksPath +} + +// generateselfSignedCert generates a dummy self-signed certificate +func generateselfSignedCert(t *testing.T) selfSignedCert { + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "Test Certificate", + Organization: []string{"Test Org"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) + require.NoError(t, err) + + return selfSignedCert{ + certPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), + keyPEM: pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privKey)}), + certDER: certDER, + } +} + +// createTestPKCS12 creates a temporary PKCS#12 keystore +func createTestPKCS12(t *testing.T, certPEM, keyPEM []byte) string { + t.Helper() + + // Decode certificate + block, _ := pem.Decode(certPEM) + require.NotNil(t, block, "failed to parse certificate PEM") + + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + + // Decode private key + block, _ = pem.Decode(keyPEM) + require.NotNil(t, block, "failed to parse private key PEM") + + privKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + require.NoError(t, err) + + // Encode PKCS#12 keystore + pfxData, err := pkcs12.Modern.Encode(privKey, cert, nil, "test-password") + require.NoError(t, err) + + // Use `t.TempDir()` to ensure cleanup + tempDir := t.TempDir() + pkcs12Path := filepath.Join(tempDir, "test-keystore.p12") + + err = os.WriteFile(pkcs12Path, pfxData, 0600) + require.NoError(t, err) + + pkcs12Path = filepath.ToSlash(pkcs12Path) + if !strings.HasPrefix(pkcs12Path, "/") { + pkcs12Path = "/" + pkcs12Path + } + + return "pkcs12://" + pkcs12Path +} + +// createTestJKS creates a temporary JKS keystore +func createTestJKS(t *testing.T, certDER []byte) string { + t.Helper() + + // Use `t.TempDir()` to ensure cleanup + tempDir := t.TempDir() + jksPath := filepath.Join(tempDir, "test-keystore.jks") + + // Create JKS keystore and add a trusted certificate + jks := keystore.New() + err := jks.SetTrustedCertificateEntry("test-alias", keystore.TrustedCertificateEntry{ + Certificate: keystore.Certificate{ + Type: "X.509", + Content: certDER, + }, + }) + require.NoError(t, err) + + // Write keystore to file + output, err := os.Create(jksPath) + require.NoError(t, err) + defer output.Close() + + require.NoError(t, jks.Store(output, []byte("test-password"))) + + jksPath = filepath.ToSlash(jksPath) + if !strings.HasPrefix(jksPath, "/") { + jksPath = "/" + jksPath + } + + return "jks://" + jksPath +} + +func TestGatherKeystores(t *testing.T) { + pkcs12Path, jksPath := generateTestKeystores(t) + + tests := []struct { + name string + content string + password string + }{ + {name: "valid PKCS12 keystore", content: pkcs12Path, password: "test-password"}, + {name: "valid JKS keystore", content: jksPath, password: "test-password"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plugin := X509Cert{ + Sources: []string{test.content}, + Password: config.NewSecret([]byte(test.password)), + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + }) + } +} + +func TestGatherKeystoresFail(t *testing.T) { + pkcs12Path, jksPath := generateTestKeystores(t) + + tests := []struct { + name string + content string + password string + expected string + }{ + {name: "missing password PKCS12", content: pkcs12Path, expected: "decryption password incorrect"}, + {name: "missing password JKS", content: jksPath, expected: "got invalid digest"}, + {name: "wrong password PKCS12", content: pkcs12Path, password: "wrong-password", expected: "decryption password incorrect"}, + {name: "wrong password JKS", content: jksPath, password: "wrong-password", expected: "got invalid digest"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plugin := X509Cert{ + Sources: []string{test.content}, + Log: testutil.Logger{}, + } + if test.password != "" { + plugin.Password = config.NewSecret([]byte(test.password)) + } else { + plugin.Password = config.NewSecret(nil) + } + require.NoError(t, plugin.Init()) + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.NotEmpty(t, acc.Errors) + require.ErrorContains(t, acc.Errors[0], test.expected) + }) + } +} diff --git a/plugins/inputs/x509_cert/sample.conf b/plugins/inputs/x509_cert/sample.conf index 1c29fad9a..735c61745 100644 --- a/plugins/inputs/x509_cert/sample.conf +++ b/plugins/inputs/x509_cert/sample.conf @@ -5,7 +5,9 @@ sources = ["tcp://example.org:443", "https://influxdata.com:443", "smtp://mail.localhost:25", "udp://127.0.0.1:4433", "/etc/ssl/certs/ssl-cert-snakeoil.pem", - "/etc/mycerts/*.mydomain.org.pem", "file:///path/to/*.pem"] + "/etc/mycerts/*.mydomain.org.pem", "file:///path/to/*.pem", + "jks:///etc/mycerts/keystore.jks", + "pkcs12:///etc/mycerts/keystore.p12"] ## Timeout for SSL connection # timeout = "5s" @@ -22,6 +24,9 @@ ## Pad certificate serial number with zeroes to 128-bits. # pad_serial_with_zeroes = false + ## Password to be used with PKCS#12 or JKS files + # password = "" + ## Optional TLS Config # tls_ca = "/etc/telegraf/ca.pem" # tls_cert = "/etc/telegraf/cert.pem" diff --git a/plugins/inputs/x509_cert/x509_cert.go b/plugins/inputs/x509_cert/x509_cert.go index 4962997f9..25d25b6c2 100644 --- a/plugins/inputs/x509_cert/x509_cert.go +++ b/plugins/inputs/x509_cert/x509_cert.go @@ -43,6 +43,7 @@ type X509Cert struct { Sources []string `toml:"sources"` Timeout config.Duration `toml:"timeout"` ServerName string `toml:"server_name"` + Password config.Secret `toml:"password"` ExcludeRootCerts bool `toml:"exclude_root_certs"` PadSerial bool `toml:"pad_serial_with_zeroes"` Log telegraf.Logger `toml:"-"` @@ -446,6 +447,12 @@ func (c *X509Cert) getCert(u *url.URL, timeout time.Duration) ([]*x509.Certifica ocspresp := tlsConn.ConnectionState().OCSPResponse return certs, &ocspresp, nil + case "jks": + certs, err := c.processJKS(u.Path) + return certs, nil, err + case "pkcs12": + certs, err := c.processPKCS12(u.Path) + return certs, nil, err default: return nil, nil, fmt.Errorf("unsupported scheme %q in location %s", u.Scheme, u.String()) }