From 96cab901c123e19ac51a5fa5eb55b1aece62dfbe Mon Sep 17 00:00:00 2001 From: Vlad Vitan <23100181+vlasebian@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:55:48 +0200 Subject: [PATCH 1/3] all: Validate root CA certificates are CA certs Reject non-CA (leaf) certificates configured as root CAs to prevent misconfiguration where a server leaf certificate is set as a root CA. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/config/tlsconfig/config.go | 48 ++++++++++- pkg/config/tlsconfig/config_test.go | 122 +++++++++++++++++++++++++++- 2 files changed, 166 insertions(+), 4 deletions(-) diff --git a/pkg/config/tlsconfig/config.go b/pkg/config/tlsconfig/config.go index 1e3d23b50b..6dcb9b57c9 100644 --- a/pkg/config/tlsconfig/config.go +++ b/pkg/config/tlsconfig/config.go @@ -16,9 +16,11 @@ package tlsconfig import ( + "bytes" "context" "crypto/tls" "crypto/x509" + "encoding/pem" "os" "strings" "sync/atomic" @@ -46,6 +48,14 @@ var ( errMissingACMEDir = errors.Define("missing_acme_dir", "missing ACME storage directory") errMissingACMEEndpoint = errors.Define("missing_acme_endpoint", "missing ACME endpoint") errMissingACMEDefaultHost = errors.Define("missing_acme_default_host", "missing ACME default host") + errParsePEM = errors.DefineInvalidArgument("parse_pem", "parse PEM") + errUnexpectedPEMType = errors.DefineInvalidArgument( + "unexpected_pem_type", "unexpected PEM block of type `{pem_type}`, expected CERTIFICATE", + ) + errParseCertificate = errors.DefineInvalidArgument("parse_certificate", "parse certificate") + errNotCACertificate = errors.DefineInvalidArgument( + "not_ca_certificate", "certificate with subject `{subject}` is not a CA certificate", + ) ) // Initialize initializes the autocert manager for the ACME configuration. @@ -190,17 +200,53 @@ func (c Client) ApplyTo(tlsConfig *tls.Config) error { } if len(rootCABytes) > 0 { + certs, err := parseAndValidateCACerts(rootCABytes) + if err != nil { + return err + } + if tlsConfig.RootCAs == nil { if tlsConfig.RootCAs, err = x509.SystemCertPool(); err != nil { tlsConfig.RootCAs = x509.NewCertPool() } } - tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) + + for _, cert := range certs { + tlsConfig.RootCAs.AddCert(cert) + } } tlsConfig.InsecureSkipVerify = c.InsecureSkipVerify return nil } +func parseAndValidateCACerts(pemBytes []byte) ([]*x509.Certificate, error) { + var certs []*x509.Certificate + for { + pemBytes = bytes.TrimSpace(pemBytes) + if len(pemBytes) == 0 { + break + } + block, rest := pem.Decode(pemBytes) + if block == nil { + return nil, errParsePEM.New() + } + if block.Type != "CERTIFICATE" { + return nil, errUnexpectedPEMType.WithAttributes("pem_type", block.Type) + } + pemBytes = rest + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, errParseCertificate.WithCause(err) + } + if !cert.IsCA { + return nil, errNotCACertificate.WithAttributes("subject", cert.Subject.String()) + } + certs = append(certs, cert) + } + return certs, nil +} + func readCert(fileReader FileReader, certFile, keyFile string) (*tls.Certificate, error) { readFile := os.ReadFile if fileReader != nil { diff --git a/pkg/config/tlsconfig/config_test.go b/pkg/config/tlsconfig/config_test.go index a976d44d66..2faa1007fa 100644 --- a/pkg/config/tlsconfig/config_test.go +++ b/pkg/config/tlsconfig/config_test.go @@ -42,7 +42,7 @@ func (m mockFileReader) ReadFile(name string) ([]byte, error) { return nil, fmt.Errorf("not found") } -func genCert() (certPEM []byte, keyPEM []byte) { +func genCertWithOptions(isCA bool) (certPEM []byte, keyPEM []byte) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { panic(err) @@ -60,10 +60,13 @@ func genCert() (certPEM []byte, keyPEM []byte) { }, NotBefore: now, NotAfter: now.Add(time.Hour), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, - IsCA: true, + IsCA: isCA, + } + if isCA { + cert.KeyUsage |= x509.KeyUsageCertSign } certBytes, err := x509.CreateCertificate(rand.Reader, cert, cert, key.Public(), key) if err != nil { @@ -80,6 +83,10 @@ func genCert() (certPEM []byte, keyPEM []byte) { }) } +func genCert() (certPEM []byte, keyPEM []byte) { + return genCertWithOptions(true) +} + func TestApplyTLSClientConfig(t *testing.T) { t.Parallel() a := assertions.New(t) @@ -107,6 +114,115 @@ func TestApplyTLSClientConfig(t *testing.T) { }) } +func TestApplyTLSClientConfigRejectsLeafCert(t *testing.T) { + t.Parallel() + leafCert, _ := genCertWithOptions(false) + caCert, _ := genCertWithOptions(true) + + t.Run("LeafCertificate", func(t *testing.T) { + t.Parallel() + a := assertions.New(t) + tlsConfig := &tls.Config{} //nolint:gosec + err := (&tlsconfig.Client{ + FileReader: mockFileReader{ + "leaf.pem": leafCert, + }, + RootCA: "leaf.pem", + }).ApplyTo(tlsConfig) + a.So(err, should.NotBeNil) + a.So(err.Error(), should.ContainSubstring, "not a CA certificate") + }) + + t.Run("CACertificate", func(t *testing.T) { + t.Parallel() + a := assertions.New(t) + tlsConfig := &tls.Config{} //nolint:gosec + err := (&tlsconfig.Client{ + FileReader: mockFileReader{ + "ca.pem": caCert, + }, + RootCA: "ca.pem", + }).ApplyTo(tlsConfig) + a.So(err, should.BeNil) + a.So(tlsConfig.RootCAs, should.NotBeNil) + }) + + t.Run("MultipleCertsOneLeaf", func(t *testing.T) { + t.Parallel() + a := assertions.New(t) + combined := append(caCert, leafCert...) + tlsConfig := &tls.Config{} //nolint:gosec + err := (&tlsconfig.Client{ + FileReader: mockFileReader{ + "mixed.pem": combined, + }, + RootCA: "mixed.pem", + }).ApplyTo(tlsConfig) + a.So(err, should.NotBeNil) + a.So(err.Error(), should.ContainSubstring, "not a CA certificate") + }) + + t.Run("MultipleCACertificates", func(t *testing.T) { + t.Parallel() + a := assertions.New(t) + caCert2, _ := genCertWithOptions(true) + combined := append(caCert, caCert2...) + tlsConfig := &tls.Config{} //nolint:gosec + err := (&tlsconfig.Client{ + FileReader: mockFileReader{ + "cas.pem": combined, + }, + RootCA: "cas.pem", + }).ApplyTo(tlsConfig) + a.So(err, should.BeNil) + a.So(tlsConfig.RootCAs, should.NotBeNil) + }) + + t.Run("InvalidPEM", func(t *testing.T) { + t.Parallel() + a := assertions.New(t) + tlsConfig := &tls.Config{} //nolint:gosec + err := (&tlsconfig.Client{ + FileReader: mockFileReader{ + "bad.pem": []byte("not a pem"), + }, + RootCA: "bad.pem", + }).ApplyTo(tlsConfig) + a.So(err, should.NotBeNil) + a.So(err.Error(), should.ContainSubstring, "parse PEM") + }) + + t.Run("NonCertificatePEMBlock", func(t *testing.T) { + t.Parallel() + a := assertions.New(t) + _, keyPEM := genCertWithOptions(true) + tlsConfig := &tls.Config{} //nolint:gosec + err := (&tlsconfig.Client{ + FileReader: mockFileReader{ + "key.pem": keyPEM, + }, + RootCA: "key.pem", + }).ApplyTo(tlsConfig) + a.So(err, should.NotBeNil) + a.So(err.Error(), should.ContainSubstring, "unexpected PEM block") + }) + + t.Run("TrailingNewlines", func(t *testing.T) { + t.Parallel() + a := assertions.New(t) + withNewlines := append(caCert, '\n', '\n', '\n') + tlsConfig := &tls.Config{} //nolint:gosec + err := (&tlsconfig.Client{ + FileReader: mockFileReader{ + "ca.pem": withNewlines, + }, + RootCA: "ca.pem", + }).ApplyTo(tlsConfig) + a.So(err, should.BeNil) + a.So(tlsConfig.RootCAs, should.NotBeNil) + }) +} + func TestApplyTLSServerAuth(t *testing.T) { t.Parallel() a := assertions.New(t) From d8a97de13ad51638e9f48dff10f5ff9f646e465e Mon Sep 17 00:00:00 2001 From: Vlad Vitan <23100181+vlasebian@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:01:36 +0200 Subject: [PATCH 2/3] dev: Update changelog for root CA validation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 564b57033b..bc3b946477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ For details about compatibility between different releases, see the **Commitment - Add tracing for LBS LNS and TTIGW protocol handlers. - TTGC LBS Root CUPS claiming support. +- Validate that root CA certificates are actually CA certificates on startup of The Things Stack, rejecting leaf certificates configured as root CAs. ### Changed From 3b900e66c9369d670f728ddc234f621e33acad39 Mon Sep 17 00:00:00 2001 From: Vlad Vitan <23100181+vlasebian@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:05:03 +0200 Subject: [PATCH 3/3] dev: Update error messages --- config/messages.json | 36 ++++++++++++++++++++++++++++++++++++ pkg/webui/locales/ja.json | 4 ++++ 2 files changed, 40 insertions(+) diff --git a/config/messages.json b/config/messages.json index 891c527d7f..9b0da95575 100644 --- a/config/messages.json +++ b/config/messages.json @@ -3671,6 +3671,33 @@ "file": "config.go" } }, + "error:pkg/config/tlsconfig:not_ca_certificate": { + "translations": { + "en": "certificate with subject `{subject}` is not a CA certificate" + }, + "description": { + "package": "pkg/config/tlsconfig", + "file": "config.go" + } + }, + "error:pkg/config/tlsconfig:parse_certificate": { + "translations": { + "en": "parse certificate" + }, + "description": { + "package": "pkg/config/tlsconfig", + "file": "config.go" + } + }, + "error:pkg/config/tlsconfig:parse_pem": { + "translations": { + "en": "parse PEM" + }, + "description": { + "package": "pkg/config/tlsconfig", + "file": "config.go" + } + }, "error:pkg/config/tlsconfig:tls_cipher_suite_invalid": { "translations": { "en": "invalid TLS cipher suite {cipher}" @@ -3707,6 +3734,15 @@ "file": "config.go" } }, + "error:pkg/config/tlsconfig:unexpected_pem_type": { + "translations": { + "en": "unexpected PEM block of type `{pem_type}`, expected CERTIFICATE" + }, + "description": { + "package": "pkg/config/tlsconfig", + "file": "config.go" + } + }, "error:pkg/config:format": { "translations": { "en": "invalid format `{input}`" diff --git a/pkg/webui/locales/ja.json b/pkg/webui/locales/ja.json index c25933935d..9ee58e6903 100644 --- a/pkg/webui/locales/ja.json +++ b/pkg/webui/locales/ja.json @@ -2296,10 +2296,14 @@ "error:pkg/config/tlsconfig:missing_acme_default_host": "", "error:pkg/config/tlsconfig:missing_acme_dir": "ACEMの保存先が見つかりません", "error:pkg/config/tlsconfig:missing_acme_endpoint": "ACMEエンドポイントが見つかりません", + "error:pkg/config/tlsconfig:not_ca_certificate": "", + "error:pkg/config/tlsconfig:parse_certificate": "", + "error:pkg/config/tlsconfig:parse_pem": "", "error:pkg/config/tlsconfig:tls_cipher_suite_invalid": "無効なTLS暗号スイート{cipher}", "error:pkg/config/tlsconfig:tls_config_source_invalid": "無効なTLS設定ソース `{source}`", "error:pkg/config/tlsconfig:tls_key_vault_id": "無効なTLS鍵のID", "error:pkg/config/tlsconfig:tls_source_empty": "TLSソースが空", + "error:pkg/config/tlsconfig:unexpected_pem_type": "", "error:pkg/config:format": "無効なフォーマット `{input}`", "error:pkg/config:missing_blob_config": "Blobストア設定が見つかりません", "error:pkg/config:unknown_blob_provider": "無効なBlobストアプロバイダ `{provider}`",