feat(inputs.x509_cert): Add support for JKS and PKCS#12 keystores (#16508)

Signed-off-by: Paulo Dias <paulodias.gm@gmail.com>
This commit is contained in:
Paulo Dias 2025-03-10 07:11:57 +00:00 committed by GitHub
parent f7bd90f194
commit d231442040
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 329 additions and 2 deletions

View File

@ -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

2
go.mod
View File

@ -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 (

4
go.sum
View File

@ -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=

View File

@ -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"

View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -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"

View File

@ -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())
}