Browse Source
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 <noreply@anthropic.com>pull/15205/head
1 changed files with 241 additions and 0 deletions
@ -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<Path> 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; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue