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..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
@@ -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;
@@ -112,6 +115,8 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
private String certFileResource;
@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,33 @@ 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);
+ 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..21688abc50 100644
--- a/application/src/main/resources/thingsboard.yml
+++ b/application/src/main/resources/thingsboard.yml
@@ -1487,10 +1487,17 @@ 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 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}"
- # Private key file associated with the Cert certificate. This key is used in the encryption process during a secure connection
+ # 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}"
+ # 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}"
# Maximum length of telemetry (time-series and attributes) message the cloud sends to the edge. By default, there is no limitation.
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..04a1b87833
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java
@@ -0,0 +1,272 @@
+/**
+ * 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 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.bouncycastle.util.io.pem.PemObject;
+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;
+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.security.spec.ECGenParameterSpec;
+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;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * 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 {
+
+ static {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ }
+
+ 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;
+ 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;
+
+ @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);
+ }
+ }
+
+ @ParameterizedTest(name = "separateCertAndKey_{0}")
+ @EnumSource(KeyType.class)
+ void separateCertAndKey(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());
+
+ server = startServer(certFile.toString(), keyFile.toString(), null);
+ assertTlsConnectivity(cert);
+ }
+
+ @ParameterizedTest(name = "combinedPemWithCertAndKey_{0}")
+ @EnumSource(KeyType.class)
+ void combinedPemWithCertAndKey(KeyType keyType) throws Exception {
+ KeyPair kp = keyType.generateKeyPair();
+ X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg);
+
+ Path combinedFile = writeTempPem("combined", cert, kp.getPrivate());
+
+ server = startServer(combinedFile.toString(), "", null);
+ assertTlsConnectivity(cert);
+ }
+
+ // 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());
+
+ server = startServer(combinedFile.toString(), "", password);
+ assertTlsConnectivity(cert);
+ }
+
+ @ParameterizedTest(name = "combinedPemWithCertOnly_throwsException_{0}")
+ @EnumSource(KeyType.class)
+ void combinedPemWithCertOnly_throwsException(KeyType keyType) throws Exception {
+ KeyPair kp = keyType.generateKeyPair();
+ X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg);
+
+ Path certOnlyFile = writeTempPem("cert-only", cert);
+
+ assertThatThrownBy(() -> startServer(certOnlyFile.toString(), "", null))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ // --- Server startup using production EdgeGrpcService.setupSsl() ---
+
+ private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception {
+ 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);
+ var clientSsl = GrpcSslContexts.forClient()
+ .trustManager(new ByteArrayInputStream(certPem.getBytes(StandardCharsets.UTF_8)))
+ .build();
+
+ channel = NettyChannelBuilder.forAddress("localhost", server.getPort())
+ .sslContext(clientSsl)
+ .build();
+
+ channel.getState(true); // trigger connection attempt
+ await().atMost(AbstractWebTest.TIMEOUT, 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 ---
+
+ private X509Certificate generateSelfSignedCert(KeyPair kp, String sigAlg) 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(sigAlg).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(toPkcs8IfKey(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;
+ }
+
+ private Object toPkcs8IfKey(Object o) {
+ if (o instanceof PrivateKey pk) {
+ return new PemObject("PRIVATE KEY", pk.getEncoded());
+ }
+ return o;
+ }
+}