From 9de53e77811d8d5be7b3cdcc28e4c650cb4e08e7 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 19:12:10 +0100 Subject: [PATCH 01/10] Support combined PEM cert+key for Edge gRPC SSL Reuse PemSslCredentials (already handles combined PEM, separate files, and encrypted keys) instead of duplicating PEM parsing logic. Wire it into gRPC via GrpcSslContexts + KeyManagerFactory. - Make private_key config optional (default empty) for combined PEM - Add key_password config for encrypted private keys Co-Authored-By: Claude Opus 4.6 --- .../service/edge/rpc/EdgeGrpcService.java | 27 ++++++++++++++----- .../src/main/resources/thingsboard.yml | 9 ++++--- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index 03228c52d4..e6c2824da3 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -19,7 +19,10 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import io.grpc.Server; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; import io.grpc.stub.StreamObserver; import jakarta.annotation.Nullable; import jakarta.annotation.PreDestroy; @@ -37,7 +40,8 @@ import org.thingsboard.server.cache.TbTransactionalCache; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; -import org.thingsboard.server.common.data.ResourceUtils; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.id.EdgeId; @@ -67,7 +71,6 @@ import org.thingsboard.server.service.edge.EdgeContextComponent; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import java.io.IOException; -import java.io.InputStream; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -110,8 +113,10 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private boolean sslEnabled; @Value("${edges.rpc.ssl.cert}") private String certFileResource; - @Value("${edges.rpc.ssl.private_key}") + @Value("${edges.rpc.ssl.private_key:}") private String privateKeyResource; + @Value("${edges.rpc.ssl.key_password:}") + private String keyPassword; @Value("${edges.state.persistToTelemetry:false}") private boolean persistToTelemetry; @Value("${edges.rpc.client_max_keep_alive_time_sec:1}") @@ -176,9 +181,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i .addService(this); if (sslEnabled) { try { - InputStream certFileIs = ResourceUtils.getInputStream(this, certFileResource); - InputStream privateKeyFileIs = ResourceUtils.getInputStream(this, privateKeyResource); - builder.useTransportSecurity(certFileIs, privateKeyFileIs); + setupSsl(builder); } catch (Exception e) { log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e); throw new RuntimeException("Unable to set up SSL context!", e); @@ -199,6 +202,18 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i log.info("Edge RPC service initialized!"); } + private void setupSsl(NettyServerBuilder builder) throws Exception { + PemSslCredentials credentials = new PemSslCredentials(); + credentials.setCertFile(certFileResource); + credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource); + credentials.setKeyPassword(keyPassword); + credentials.init(false); + + SslContext sslContext = GrpcSslContexts.configure( + SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build(); + builder.sslContext(sslContext); + } + @PreDestroy public void destroy() { if (server != null) { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 751d1c0e0b..5c71a834c3 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1487,10 +1487,13 @@ edges: ssl: # Enable/disable SSL support enabled: "${EDGES_RPC_SSL_ENABLED:false}" - # Cert file to be used during TLS connectivity to the cloud + # Path to the server certificate PEM file. May contain the full chain and optionally the private key (combined PEM). + # For a combined PEM file containing both the certificate chain and the private key, leave private_key empty. cert: "${EDGES_RPC_SSL_CERT:certChainFile.pem}" - # Private key file associated with the Cert certificate. This key is used in the encryption process during a secure connection - private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:privateKeyFile.pem}" + # Path to the private key PEM file. Optional when the key is already included in the cert file (combined PEM). + private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:}" + # Password for encrypted private keys. Optional, only needed for password-protected keys. + key_password: "${EDGES_RPC_SSL_KEY_PASSWORD:}" # Maximum size (in bytes) of inbound messages the cloud can handle from the edge. By default, it can handle messages up to 4 Megabytes max_inbound_message_size: "${EDGES_RPC_MAX_INBOUND_MESSAGE_SIZE:4194304}" # Maximum length of telemetry (time-series and attributes) message the cloud sends to the edge. By default, there is no limitation. From 8f99510d07d40850f2e166f19db3713d92e5252f Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 19:16:06 +0100 Subject: [PATCH 02/10] Restore default private_key value for backward compatibility Keep privateKeyFile.pem as the default so existing users with separate cert/key file configs are not affected. Co-Authored-By: Claude Opus 4.6 --- .../thingsboard/server/service/edge/rpc/EdgeGrpcService.java | 2 +- application/src/main/resources/thingsboard.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index e6c2824da3..1bb61910ec 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -113,7 +113,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private boolean sslEnabled; @Value("${edges.rpc.ssl.cert}") private String certFileResource; - @Value("${edges.rpc.ssl.private_key:}") + @Value("${edges.rpc.ssl.private_key}") private String privateKeyResource; @Value("${edges.rpc.ssl.key_password:}") private String keyPassword; diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 5c71a834c3..91c475ec4e 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1491,7 +1491,7 @@ edges: # For a combined PEM file containing both the certificate chain and the private key, leave private_key empty. cert: "${EDGES_RPC_SSL_CERT:certChainFile.pem}" # Path to the private key PEM file. Optional when the key is already included in the cert file (combined PEM). - private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:}" + private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:privateKeyFile.pem}" # Password for encrypted private keys. Optional, only needed for password-protected keys. key_password: "${EDGES_RPC_SSL_KEY_PASSWORD:}" # Maximum size (in bytes) of inbound messages the cloud can handle from the edge. By default, it can handle messages up to 4 Megabytes From 521efe106797f2f004d01d5217d1d6adbeca9700 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 19:37:10 +0100 Subject: [PATCH 03/10] Add EdgeGrpcSslTest covering all SSL setup scenarios Tests mirror EdgeGrpcService.setupSsl() using PemSslCredentials: - Separate cert and key files (existing behavior) - Combined PEM file (cert + key) - Encrypted private key with key_password - Error when no private key found Co-Authored-By: Claude Opus 4.6 --- .../service/edge/rpc/EdgeGrpcSslTest.java | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java new file mode 100644 index 0000000000..9e3aa68b12 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -0,0 +1,241 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc; + +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.TestSocketUtils; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; +import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; + +import java.io.ByteArrayInputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for Edge gRPC SSL setup using PemSslCredentials. + * Covers all test plan scenarios: + * 1. Separate cert and key files (existing behavior) + * 2. Combined PEM file (cert + key) + * 3. Encrypted private key + key_password + * 4. Error when combined PEM has no private key and private_key is empty + */ +class EdgeGrpcSslTest { + + static { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + private final List tempFiles = new ArrayList<>(); + private Server server; + private ManagedChannel channel; + + @AfterEach + void cleanup() throws Exception { + if (channel != null) { + channel.shutdownNow().awaitTermination(2, TimeUnit.SECONDS); + } + if (server != null) { + server.shutdownNow().awaitTermination(2, TimeUnit.SECONDS); + } + for (Path p : tempFiles) { + Files.deleteIfExists(p); + } + } + + @Test + void separateCertAndKeyFiles() throws Exception { + KeyPair kp = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp); + + Path certFile = writeTempPem("cert", cert); + Path keyFile = writeTempPem("key", kp.getPrivate()); + + server = startServer(certFile.toString(), keyFile.toString(), null); + assertTlsConnectivity(cert); + } + + @Test + void combinedPemFile() throws Exception { + KeyPair kp = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp); + + Path combinedFile = writeTempPem("combined", cert, kp.getPrivate()); + + server = startServer(combinedFile.toString(), "", null); + assertTlsConnectivity(cert); + } + + @Test + void encryptedPrivateKey() throws Exception { + KeyPair kp = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp); + String password = "test-password"; + + Path combinedFile = writeTempPemEncrypted("enc-combined", password, cert, kp.getPrivate()); + + server = startServer(combinedFile.toString(), "", password); + assertTlsConnectivity(cert); + } + + @Test + void combinedPemWithoutKey_throwsException() throws Exception { + KeyPair kp = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp); + + Path certOnlyFile = writeTempPem("cert-only", cert); + + assertThatThrownBy(() -> startServer(certOnlyFile.toString(), "", null)) + .isInstanceOf(IllegalArgumentException.class); + } + + // --- Helpers that mirror EdgeGrpcService.setupSsl() --- + + private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception { + PemSslCredentials credentials = new PemSslCredentials(); + credentials.setCertFile(certFileResource); + credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource); + credentials.setKeyPassword(keyPassword); + credentials.init(false); + + SslContext sslContext = GrpcSslContexts.configure( + SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build(); + + int port = TestSocketUtils.findAvailableTcpPort(); + return NettyServerBuilder.forPort(port) + .sslContext(sslContext) + .addService(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {}) + .build() + .start(); + } + + private void assertTlsConnectivity(X509Certificate trustedCert) throws Exception { + String certPem = toPem(trustedCert); + SslContext clientSsl = GrpcSslContexts.forClient() + .trustManager(new ByteArrayInputStream(certPem.getBytes(StandardCharsets.UTF_8))) + .build(); + + channel = NettyChannelBuilder.forAddress("localhost", server.getPort()) + .sslContext(clientSsl) + .build(); + + // Trigger connection and wait for READY state + channel.getState(true); + long deadline = System.currentTimeMillis() + 5_000; + while (System.currentTimeMillis() < deadline) { + var state = channel.getState(false); + if (state == io.grpc.ConnectivityState.READY) { + break; + } + if (state == io.grpc.ConnectivityState.TRANSIENT_FAILURE) { + throw new AssertionError("TLS handshake failed: channel in TRANSIENT_FAILURE"); + } + Thread.sleep(50); + } + assertThat(channel.getState(false)) + .as("Client should connect via TLS") + .isEqualTo(io.grpc.ConnectivityState.READY); + } + + // --- Cert/key generation --- + + private KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private X509Certificate generateSelfSignedCert(KeyPair kp) throws Exception { + X500Name subject = new X500Name("CN=localhost"); + Date now = new Date(); + return new JcaX509CertificateConverter().getCertificate( + new JcaX509v3CertificateBuilder( + subject, BigInteger.ONE, now, + new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)), + subject, kp.getPublic()) + .build(new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()))); + } + + // --- PEM file helpers --- + + private String toPem(Object obj) throws Exception { + java.io.StringWriter sw = new java.io.StringWriter(); + try (JcaPEMWriter w = new JcaPEMWriter(sw)) { + w.writeObject(obj); + } + return sw.toString(); + } + + private Path writeTempPem(String prefix, Object... objects) throws Exception { + Path p = Files.createTempFile(prefix + "-", ".pem"); + tempFiles.add(p); + try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) { + for (Object o : objects) { + w.writeObject(o); + } + } + return p; + } + + private Path writeTempPemEncrypted(String prefix, String password, Object... objects) throws Exception { + Path p = Files.createTempFile(prefix + "-", ".pem"); + tempFiles.add(p); + var encryptor = new JcePEMEncryptorBuilder("AES-256-CBC") + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .build(password.toCharArray()); + try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) { + for (Object o : objects) { + if (o instanceof PrivateKey) { + w.writeObject(o, encryptor); + } else { + w.writeObject(o); + } + } + } + return p; + } +} From 9e19d0818e2ee4c8a25e573f2253415fa034052d Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 19:43:52 +0100 Subject: [PATCH 04/10] Improve EdgeGrpcSslTest: use port 0 and Awaitility - Use forPort(0) instead of TestSocketUtils for random port - Replace manual poll loop with Awaitility Co-Authored-By: Claude Opus 4.6 --- .../service/edge/rpc/EdgeGrpcSslTest.java | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java index 9e3aa68b12..b86409ee23 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -31,7 +31,6 @@ import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import org.springframework.test.util.TestSocketUtils; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; @@ -53,6 +52,7 @@ import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; /** * Tests for Edge gRPC SSL setup using PemSslCredentials. @@ -145,8 +145,7 @@ class EdgeGrpcSslTest { SslContext sslContext = GrpcSslContexts.configure( SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build(); - int port = TestSocketUtils.findAvailableTcpPort(); - return NettyServerBuilder.forPort(port) + return NettyServerBuilder.forPort(0) .sslContext(sslContext) .addService(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {}) .build() @@ -163,22 +162,16 @@ class EdgeGrpcSslTest { .sslContext(clientSsl) .build(); - // Trigger connection and wait for READY state - channel.getState(true); - long deadline = System.currentTimeMillis() + 5_000; - while (System.currentTimeMillis() < deadline) { - var state = channel.getState(false); - if (state == io.grpc.ConnectivityState.READY) { - break; - } - if (state == io.grpc.ConnectivityState.TRANSIENT_FAILURE) { - throw new AssertionError("TLS handshake failed: channel in TRANSIENT_FAILURE"); - } - Thread.sleep(50); - } - assertThat(channel.getState(false)) - .as("Client should connect via TLS") - .isEqualTo(io.grpc.ConnectivityState.READY); + channel.getState(true); // trigger connection attempt + await().atMost(5, TimeUnit.SECONDS) + .pollInterval(50, TimeUnit.MILLISECONDS) + .untilAsserted(() -> { + var state = channel.getState(false); + if (state == io.grpc.ConnectivityState.TRANSIENT_FAILURE) { + throw new AssertionError("TLS handshake failed: channel in TRANSIENT_FAILURE"); + } + assertThat(state).isEqualTo(io.grpc.ConnectivityState.READY); + }); } // --- Cert/key generation --- From c149927a6eed92615584b2bcee654de8f30f4501 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 19:53:03 +0100 Subject: [PATCH 05/10] Add ECDSA P-384 test, refactor to parameterized tests - Add EC_P384 key type alongside RSA_2048 - Parameterize separateCertAndKeyFiles and combinedPemFile tests - Write private keys in PKCS#8 format for EC compatibility Co-Authored-By: Claude Opus 4.6 --- .../service/edge/rpc/EdgeGrpcSslTest.java | 77 ++++++++++++++----- 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java index b86409ee23..96a27cdb4b 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -28,9 +28,12 @@ import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openssl.jcajce.JcaPEMWriter; import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder; +import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; @@ -45,6 +48,7 @@ import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.Security; import java.security.cert.X509Certificate; +import java.security.spec.ECGenParameterSpec; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -61,6 +65,7 @@ import static org.awaitility.Awaitility.await; * 2. Combined PEM file (cert + key) * 3. Encrypted private key + key_password * 4. Error when combined PEM has no private key and private_key is empty + * 5. ECDSA P-384 key support */ class EdgeGrpcSslTest { @@ -70,6 +75,33 @@ class EdgeGrpcSslTest { } } + enum KeyType { + RSA_2048("RSA", 2048, null, "SHA256withRSA"), + EC_P384("EC", 384, "secp384r1", "SHA384withECDSA"); + + final String algorithm; + final int size; + final String curve; + final String sigAlg; + + KeyType(String algorithm, int size, String curve, String sigAlg) { + this.algorithm = algorithm; + this.size = size; + this.curve = curve; + this.sigAlg = sigAlg; + } + + KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance(algorithm); + if (curve != null) { + kpg.initialize(new ECGenParameterSpec(curve)); + } else { + kpg.initialize(size); + } + return kpg.generateKeyPair(); + } + } + private final List tempFiles = new ArrayList<>(); private Server server; private ManagedChannel channel; @@ -87,10 +119,11 @@ class EdgeGrpcSslTest { } } - @Test - void separateCertAndKeyFiles() throws Exception { - KeyPair kp = generateKeyPair(); - X509Certificate cert = generateSelfSignedCert(kp); + @ParameterizedTest(name = "separateCertAndKeyFiles_{0}") + @EnumSource(KeyType.class) + void separateCertAndKeyFiles(KeyType keyType) throws Exception { + KeyPair kp = keyType.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); Path certFile = writeTempPem("cert", cert); Path keyFile = writeTempPem("key", kp.getPrivate()); @@ -99,10 +132,11 @@ class EdgeGrpcSslTest { assertTlsConnectivity(cert); } - @Test - void combinedPemFile() throws Exception { - KeyPair kp = generateKeyPair(); - X509Certificate cert = generateSelfSignedCert(kp); + @ParameterizedTest(name = "combinedPemFile_{0}") + @EnumSource(KeyType.class) + void combinedPemFile(KeyType keyType) throws Exception { + KeyPair kp = keyType.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); Path combinedFile = writeTempPem("combined", cert, kp.getPrivate()); @@ -112,8 +146,8 @@ class EdgeGrpcSslTest { @Test void encryptedPrivateKey() throws Exception { - KeyPair kp = generateKeyPair(); - X509Certificate cert = generateSelfSignedCert(kp); + KeyPair kp = KeyType.RSA_2048.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, KeyType.RSA_2048.sigAlg); String password = "test-password"; Path combinedFile = writeTempPemEncrypted("enc-combined", password, cert, kp.getPrivate()); @@ -124,8 +158,8 @@ class EdgeGrpcSslTest { @Test void combinedPemWithoutKey_throwsException() throws Exception { - KeyPair kp = generateKeyPair(); - X509Certificate cert = generateSelfSignedCert(kp); + KeyPair kp = KeyType.RSA_2048.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, KeyType.RSA_2048.sigAlg); Path certOnlyFile = writeTempPem("cert-only", cert); @@ -176,13 +210,7 @@ class EdgeGrpcSslTest { // --- Cert/key generation --- - private KeyPair generateKeyPair() throws Exception { - KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); - kpg.initialize(2048); - return kpg.generateKeyPair(); - } - - private X509Certificate generateSelfSignedCert(KeyPair kp) throws Exception { + private X509Certificate generateSelfSignedCert(KeyPair kp, String sigAlg) throws Exception { X500Name subject = new X500Name("CN=localhost"); Date now = new Date(); return new JcaX509CertificateConverter().getCertificate( @@ -190,7 +218,7 @@ class EdgeGrpcSslTest { subject, BigInteger.ONE, now, new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)), subject, kp.getPublic()) - .build(new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()))); + .build(new JcaContentSignerBuilder(sigAlg).build(kp.getPrivate()))); } // --- PEM file helpers --- @@ -208,7 +236,7 @@ class EdgeGrpcSslTest { tempFiles.add(p); try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) { for (Object o : objects) { - w.writeObject(o); + w.writeObject(toPkcs8IfKey(o)); } } return p; @@ -231,4 +259,11 @@ class EdgeGrpcSslTest { } return p; } + + private Object toPkcs8IfKey(Object o) { + if (o instanceof PrivateKey pk) { + return new PemObject("PRIVATE KEY", pk.getEncoded()); + } + return o; + } } From 561a8597aae405d792375b43dcad5661da9bd5c1 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 20:00:10 +0100 Subject: [PATCH 06/10] Add RSA-4096 and EC-P256 key types, parameterize all tests - Add RSA_4096 and EC_P256 alongside RSA_2048 and EC_P384 - Parameterize encrypted key tests (RSA-only, EC encrypted keys are a pre-existing PemSslCredentials limitation) - 14 test scenarios total Co-Authored-By: Claude Opus 4.6 --- .../service/edge/rpc/EdgeGrpcSslTest.java | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java index 96a27cdb4b..0f37f9f199 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -77,6 +77,8 @@ class EdgeGrpcSslTest { enum KeyType { RSA_2048("RSA", 2048, null, "SHA256withRSA"), + RSA_4096("RSA", 4096, null, "SHA256withRSA"), + EC_P256("EC", 256, "secp256r1", "SHA256withECDSA"), EC_P384("EC", 384, "secp384r1", "SHA384withECDSA"); final String algorithm; @@ -144,10 +146,14 @@ class EdgeGrpcSslTest { assertTlsConnectivity(cert); } - @Test - void encryptedPrivateKey() throws Exception { - KeyPair kp = KeyType.RSA_2048.generateKeyPair(); - X509Certificate cert = generateSelfSignedCert(kp, KeyType.RSA_2048.sigAlg); + // RSA-only: BouncyCastle writes encrypted EC keys in traditional PEM format (BEGIN EC PRIVATE KEY), + // which after decryption produces a PEMKeyPair without public key info — causing PemSslCredentials + // to fail with "Cannot invoke SubjectPublicKeyInfo.getEncoded() because getPublicKeyInfo() is null". + @ParameterizedTest(name = "encryptedPrivateKey_{0}") + @EnumSource(value = KeyType.class, names = {"RSA_2048", "RSA_4096"}) + void encryptedPrivateKey(KeyType keyType) throws Exception { + KeyPair kp = keyType.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); String password = "test-password"; Path combinedFile = writeTempPemEncrypted("enc-combined", password, cert, kp.getPrivate()); @@ -156,10 +162,11 @@ class EdgeGrpcSslTest { assertTlsConnectivity(cert); } - @Test - void combinedPemWithoutKey_throwsException() throws Exception { - KeyPair kp = KeyType.RSA_2048.generateKeyPair(); - X509Certificate cert = generateSelfSignedCert(kp, KeyType.RSA_2048.sigAlg); + @ParameterizedTest(name = "combinedPemWithoutKey_{0}") + @EnumSource(KeyType.class) + void combinedPemWithoutKey_throwsException(KeyType keyType) throws Exception { + KeyPair kp = keyType.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); Path certOnlyFile = writeTempPem("cert-only", cert); From c65b172d7ef757e08be56d7cef6d2695519c006e Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 20:07:19 +0100 Subject: [PATCH 07/10] Improve test method names for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - separateCertAndKey — separate cert and key PEM inputs - combinedPemWithCertAndKey — combined PEM, both present - combinedPemWithCertOnly — combined PEM, key missing → error - encryptedPrivateKey — encrypted key with password Co-Authored-By: Claude Opus 4.6 --- .../server/service/edge/rpc/EdgeGrpcSslTest.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java index 0f37f9f199..4401738f5d 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -121,9 +121,9 @@ class EdgeGrpcSslTest { } } - @ParameterizedTest(name = "separateCertAndKeyFiles_{0}") + @ParameterizedTest(name = "separateCertAndKey_{0}") @EnumSource(KeyType.class) - void separateCertAndKeyFiles(KeyType keyType) throws Exception { + void separateCertAndKey(KeyType keyType) throws Exception { KeyPair kp = keyType.generateKeyPair(); X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); @@ -134,9 +134,9 @@ class EdgeGrpcSslTest { assertTlsConnectivity(cert); } - @ParameterizedTest(name = "combinedPemFile_{0}") + @ParameterizedTest(name = "combinedPemWithCertAndKey_{0}") @EnumSource(KeyType.class) - void combinedPemFile(KeyType keyType) throws Exception { + void combinedPemWithCertAndKey(KeyType keyType) throws Exception { KeyPair kp = keyType.generateKeyPair(); X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); @@ -162,9 +162,9 @@ class EdgeGrpcSslTest { assertTlsConnectivity(cert); } - @ParameterizedTest(name = "combinedPemWithoutKey_{0}") + @ParameterizedTest(name = "combinedPemWithCertOnly_throwsException_{0}") @EnumSource(KeyType.class) - void combinedPemWithoutKey_throwsException(KeyType keyType) throws Exception { + void combinedPemWithCertOnly_throwsException(KeyType keyType) throws Exception { KeyPair kp = keyType.generateKeyPair(); X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); From 4d2545052d4c6453ef726eb1df379c7d520fb6b5 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 20:19:02 +0100 Subject: [PATCH 08/10] Use production EdgeGrpcService.setupSsl() in tests instead of copy-pasted logic Make setupSsl package-private so the test can call it directly, ensuring tests exercise the real production code path. Co-Authored-By: Claude Opus 4.6 --- .../service/edge/rpc/EdgeGrpcService.java | 2 +- .../service/edge/rpc/EdgeGrpcSslTest.java | 53 +++++++++---------- 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index 1bb61910ec..5a8fabbf3d 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -202,7 +202,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i log.info("Edge RPC service initialized!"); } - private void setupSsl(NettyServerBuilder builder) throws Exception { + void setupSsl(NettyServerBuilder builder) throws Exception { PemSslCredentials credentials = new PemSslCredentials(); credentials.setCertFile(certFileResource); credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource); diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java index 4401738f5d..ed736ebdfb 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -20,22 +20,18 @@ import io.grpc.Server; import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; -import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; -import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openssl.jcajce.JcaPEMWriter; import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder; -import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; -import org.thingsboard.server.common.data.StringUtils; -import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; +import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; import java.io.ByteArrayInputStream; @@ -59,13 +55,15 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.awaitility.Awaitility.await; /** - * Tests for Edge gRPC SSL setup using PemSslCredentials. - * Covers all test plan scenarios: - * 1. Separate cert and key files (existing behavior) - * 2. Combined PEM file (cert + key) - * 3. Encrypted private key + key_password - * 4. Error when combined PEM has no private key and private_key is empty - * 5. ECDSA P-384 key support + * Tests for Edge gRPC SSL setup using the production {@link EdgeGrpcService#setupSsl} method. + *

+ * Covers: + * 1. Separate cert and key PEM inputs + * 2. Combined PEM (cert + key in one file) + * 3. Encrypted private key with password + * 4. Missing key in combined PEM → error + *

+ * Each scenario is parameterized across key types: RSA-2048, RSA-4096, EC P-256, EC P-384. */ class EdgeGrpcSslTest { @@ -174,28 +172,25 @@ class EdgeGrpcSslTest { .isInstanceOf(IllegalArgumentException.class); } - // --- Helpers that mirror EdgeGrpcService.setupSsl() --- + // --- Server startup using production EdgeGrpcService.setupSsl() --- private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception { - PemSslCredentials credentials = new PemSslCredentials(); - credentials.setCertFile(certFileResource); - credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource); - credentials.setKeyPassword(keyPassword); - credentials.init(false); - - SslContext sslContext = GrpcSslContexts.configure( - SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build(); - - return NettyServerBuilder.forPort(0) - .sslContext(sslContext) - .addService(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {}) - .build() - .start(); + EdgeGrpcService edgeGrpcService = new EdgeGrpcService(); + ReflectionTestUtils.setField(edgeGrpcService, "certFileResource", certFileResource); + ReflectionTestUtils.setField(edgeGrpcService, "privateKeyResource", privateKeyResource); + ReflectionTestUtils.setField(edgeGrpcService, "keyPassword", keyPassword != null ? keyPassword : ""); + + NettyServerBuilder builder = NettyServerBuilder.forPort(0) + .addService(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {}); + + edgeGrpcService.setupSsl(builder); + + return builder.build().start(); } private void assertTlsConnectivity(X509Certificate trustedCert) throws Exception { String certPem = toPem(trustedCert); - SslContext clientSsl = GrpcSslContexts.forClient() + var clientSsl = GrpcSslContexts.forClient() .trustManager(new ByteArrayInputStream(certPem.getBytes(StandardCharsets.UTF_8))) .build(); From fbf690cd8ff381ea48c09ae964e5b022e8feb041 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 20:29:49 +0100 Subject: [PATCH 09/10] Reuse AbstractWebTest.TIMEOUT for Awaitility timeout in EdgeGrpcSslTest Co-Authored-By: Claude Opus 4.6 --- .../thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java index ed736ebdfb..04a1b87833 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -32,6 +32,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.controller.AbstractWebTest; import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; import java.io.ByteArrayInputStream; @@ -199,7 +200,7 @@ class EdgeGrpcSslTest { .build(); channel.getState(true); // trigger connection attempt - await().atMost(5, TimeUnit.SECONDS) + await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS) .pollInterval(50, TimeUnit.MILLISECONDS) .untilAsserted(() -> { var state = channel.getState(false); From a9287999095a146d0416b580af484559599fe627 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 10 Mar 2026 21:18:19 +0100 Subject: [PATCH 10/10] Enrich SSL configuration documentation with path resolution details Co-Authored-By: Claude Opus 4.6 --- .../server/service/edge/rpc/EdgeGrpcService.java | 15 +++++++++++++++ application/src/main/resources/thingsboard.yml | 12 ++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index 5a8fabbf3d..e1211c53a7 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -202,6 +202,21 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i log.info("Edge RPC service initialized!"); } + /** + * Configures TLS for the Edge gRPC server. + *

+ * Delegates PEM parsing and key management to {@link PemSslCredentials} — the same + * class used by MQTT, CoAP, and LwM2M transports — which supports: + *

    + *
  • Separate certificate and private key files (classic two-file setup)
  • + *
  • Combined PEM: certificate chain + private key in a single {@code cert} file + * ({@code private_key} left empty)
  • + *
  • Encrypted private keys (password supplied via {@code key_password})
  • + *
+ * Path resolution (for both {@code cert} and {@code private_key}) is handled by + * {@link org.thingsboard.server.common.data.ResourceUtils#getInputStream ResourceUtils}: + * absolute path → relative / working-dir → classpath → {@code classpath:} prefix. + */ void setupSsl(NettyServerBuilder builder) throws Exception { PemSslCredentials credentials = new PemSslCredentials(); credentials.setCertFile(certFileResource); diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 91c475ec4e..21688abc50 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1487,12 +1487,16 @@ edges: ssl: # Enable/disable SSL support enabled: "${EDGES_RPC_SSL_ENABLED:false}" - # Path to the server certificate PEM file. May contain the full chain and optionally the private key (combined PEM). - # For a combined PEM file containing both the certificate chain and the private key, leave private_key empty. + # Path to the server certificate file (holds server certificate or certificate chain, may include server private key). + # Accepts an absolute filesystem path (e.g. /etc/thingsboard/certChainFile.pem), + # a relative path resolved against the working directory first then the classpath, + # or a classpath resource with the explicit "classpath:" prefix (e.g. classpath:conf/certChainFile.pem). cert: "${EDGES_RPC_SSL_CERT:certChainFile.pem}" - # Path to the private key PEM file. Optional when the key is already included in the cert file (combined PEM). + # Path to the server certificate private key file. Optional if the private key is already present in the cert file above. + # Supports the same path resolution as 'cert': absolute, relative/classpath, or "classpath:" prefix. + # Leave empty when using a combined PEM cert that already contains the private key. private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:privateKeyFile.pem}" - # Password for encrypted private keys. Optional, only needed for password-protected keys. + # Server certificate private key password (optional). Leave empty if the key is not encrypted. key_password: "${EDGES_RPC_SSL_KEY_PASSWORD:}" # Maximum size (in bytes) of inbound messages the cloud can handle from the edge. By default, it can handle messages up to 4 Megabytes max_inbound_message_size: "${EDGES_RPC_MAX_INBOUND_MESSAGE_SIZE:4194304}"