Skip to content

Commit f263ec3

Browse files
committed
feat: extract BigDataTlsCertificates utility, add Java truststore handling, and refine TLS support
- Extracted `BigDataTlsCertificates` for centralized TLS certificate and truststore management. - Added APIs for generating, reusing, and installing Java truststores, including JUnit examples. - Updated `TlsMaterial` to leverage `BigDataTlsCertificates`, reducing redundancy. - Enhanced user guide with detailed TLS CA, truststore creation, and `javax.net.ssl.trustStore` usage instructions. - Introduced unit tests for `BigDataTlsCertificates` covering new functionalities. - Bumped `org.openprojectx.spark.platform` to `0.1.41-SNAPSHOT`.
1 parent 7df6c4a commit f263ec3

6 files changed

Lines changed: 541 additions & 129 deletions

File tree

core/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,7 @@ dependencies {
1010
api(libs.testcontainersPostgresql)
1111
api(libs.testcontainersKafka)
1212
api(libs.hiveDockerTestcontainers)
13+
testImplementation(libs.junitJupiterApi)
14+
testRuntimeOnly(libs.junitJupiterEngine)
15+
testRuntimeOnly(libs.junitPlatformLauncher)
1316
}
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
package org.openprojectx.bigdata.test.core
2+
3+
import java.io.StringReader
4+
import java.io.StringWriter
5+
import java.math.BigInteger
6+
import java.nio.charset.StandardCharsets
7+
import java.nio.file.Files
8+
import java.nio.file.Path
9+
import java.nio.file.StandardCopyOption
10+
import java.security.KeyPair
11+
import java.security.KeyPairGenerator
12+
import java.security.KeyStore
13+
import java.security.PrivateKey
14+
import java.security.SecureRandom
15+
import java.security.Security
16+
import java.security.cert.X509Certificate
17+
import java.time.Instant
18+
import java.time.temporal.ChronoUnit
19+
import java.util.Date
20+
import org.bouncycastle.asn1.x500.X500Name
21+
import org.bouncycastle.asn1.x509.BasicConstraints
22+
import org.bouncycastle.asn1.x509.Extension
23+
import org.bouncycastle.asn1.x509.GeneralName
24+
import org.bouncycastle.asn1.x509.GeneralNames
25+
import org.bouncycastle.cert.X509CertificateHolder
26+
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
27+
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils
28+
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
29+
import org.bouncycastle.jce.provider.BouncyCastleProvider
30+
import org.bouncycastle.openssl.PEMKeyPair
31+
import org.bouncycastle.openssl.PEMParser
32+
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter
33+
import org.bouncycastle.openssl.jcajce.JcaPEMWriter
34+
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
35+
36+
data class CertificateAuthorityFiles(
37+
val certificatePath: Path,
38+
val privateKeyPath: Path,
39+
)
40+
41+
data class TrustStoreFile(
42+
val path: Path,
43+
val password: String,
44+
val type: String,
45+
val alias: String,
46+
)
47+
48+
data class JavaTrustStoreInstallResult(
49+
val javaHome: Path,
50+
val trustStorePath: Path,
51+
val backupPath: Path?,
52+
val alias: String,
53+
val type: String,
54+
)
55+
56+
object BigDataTlsCertificates {
57+
init {
58+
Security.addProvider(BouncyCastleProvider())
59+
}
60+
61+
@JvmStatic
62+
@JvmOverloads
63+
fun generateCertificateAuthority(
64+
certificatePath: Path,
65+
privateKeyPath: Path,
66+
commonName: String = "bigdata-test-root-ca",
67+
validityDays: Long = 3650,
68+
overwrite: Boolean = false,
69+
): CertificateAuthorityFiles {
70+
require(validityDays > 0) { "validityDays must be positive" }
71+
if (!overwrite) {
72+
require(!Files.exists(certificatePath)) { "CA certificate already exists: $certificatePath" }
73+
require(!Files.exists(privateKeyPath)) { "CA private key already exists: $privateKeyPath" }
74+
}
75+
76+
certificatePath.parent?.let { Files.createDirectories(it) }
77+
privateKeyPath.parent?.let { Files.createDirectories(it) }
78+
79+
val keyPair = rsaKeyPair()
80+
val certificate = selfSignedCaCertificate(keyPair, commonName, validityDays)
81+
writePem(certificatePath, certificate)
82+
writePem(privateKeyPath, keyPair.private)
83+
return CertificateAuthorityFiles(certificatePath, privateKeyPath)
84+
}
85+
86+
@JvmStatic
87+
fun ensureCertificateAuthority(
88+
certificatePath: Path,
89+
privateKeyPath: Path,
90+
): CertificateAuthorityFiles {
91+
val certExists = Files.exists(certificatePath)
92+
val keyExists = Files.exists(privateKeyPath)
93+
return when {
94+
certExists && keyExists -> CertificateAuthorityFiles(certificatePath, privateKeyPath)
95+
!certExists && !keyExists -> generateCertificateAuthority(certificatePath, privateKeyPath)
96+
else -> error("CA certificate and private key must either both exist or both be absent")
97+
}
98+
}
99+
100+
@JvmStatic
101+
@JvmOverloads
102+
fun generateTrustStore(
103+
certificatePath: Path,
104+
trustStorePath: Path,
105+
password: String = "changeit",
106+
alias: String = "bigdata-test-root-ca",
107+
type: String = "PKCS12",
108+
overwrite: Boolean = false,
109+
): TrustStoreFile {
110+
if (!overwrite) {
111+
require(!Files.exists(trustStorePath)) { "Truststore already exists: $trustStorePath" }
112+
}
113+
trustStorePath.parent?.let { Files.createDirectories(it) }
114+
val keyStore = KeyStore.getInstance(type)
115+
keyStore.load(null, password.toCharArray())
116+
keyStore.setCertificateEntry(alias, readCertificate(certificatePath))
117+
Files.newOutputStream(trustStorePath).use { output ->
118+
keyStore.store(output, password.toCharArray())
119+
}
120+
return TrustStoreFile(trustStorePath, password, type, alias)
121+
}
122+
123+
@JvmStatic
124+
@JvmOverloads
125+
fun installCertificateAuthorityToJavaTrustStore(
126+
javaHome: Path,
127+
certificatePath: Path,
128+
storePassword: String = "changeit",
129+
alias: String = "bigdata-test-root-ca",
130+
overwriteAlias: Boolean = false,
131+
backup: Boolean = true,
132+
type: String = KeyStore.getDefaultType(),
133+
): JavaTrustStoreInstallResult {
134+
val trustStorePath = javaHome.resolve("lib").resolve("security").resolve("cacerts")
135+
require(Files.isRegularFile(trustStorePath)) { "JDK truststore does not exist: $trustStorePath" }
136+
137+
val password = storePassword.toCharArray()
138+
val (keyStore, resolvedType) = loadExistingKeyStore(trustStorePath, password, type)
139+
if (keyStore.containsAlias(alias) && !overwriteAlias) {
140+
error("JDK truststore already contains alias '$alias': $trustStorePath")
141+
}
142+
143+
val backupPath = if (backup) {
144+
val path = trustStorePath.resolveSibling("cacerts.bigdata-test.bak")
145+
Files.copy(trustStorePath, path, StandardCopyOption.REPLACE_EXISTING)
146+
path
147+
} else {
148+
null
149+
}
150+
151+
keyStore.setCertificateEntry(alias, readCertificate(certificatePath))
152+
Files.newOutputStream(trustStorePath).use { output -> keyStore.store(output, password) }
153+
return JavaTrustStoreInstallResult(
154+
javaHome = javaHome,
155+
trustStorePath = trustStorePath,
156+
backupPath = backupPath,
157+
alias = alias,
158+
type = resolvedType,
159+
)
160+
}
161+
162+
private fun loadExistingKeyStore(
163+
path: Path,
164+
password: CharArray,
165+
preferredType: String,
166+
): Pair<KeyStore, String> {
167+
val types = listOf(preferredType, "JKS", "PKCS12").distinct()
168+
var lastError: Exception? = null
169+
for (type in types) {
170+
val keyStore = KeyStore.getInstance(type)
171+
try {
172+
Files.newInputStream(path).use { input -> keyStore.load(input, password) }
173+
return keyStore to type
174+
} catch (e: Exception) {
175+
lastError = e
176+
}
177+
}
178+
throw IllegalStateException("Failed to load truststore $path as ${types.joinToString()}", lastError)
179+
}
180+
181+
internal fun selfSignedCaCertificate(
182+
keyPair: KeyPair,
183+
commonName: String = "bigdata-test-root-ca",
184+
validityDays: Long = 3650,
185+
): X509Certificate {
186+
val subject = X500Name("CN=$commonName")
187+
return signedCertificate(
188+
subject = subject,
189+
subjectKeyPair = keyPair,
190+
issuer = subject,
191+
issuerKey = keyPair.private,
192+
issuerCert = null,
193+
sanDomains = emptyList(),
194+
isCa = true,
195+
validityDays = validityDays,
196+
)
197+
}
198+
199+
internal fun signedCertificate(
200+
subject: X500Name,
201+
subjectKeyPair: KeyPair,
202+
issuer: X500Name,
203+
issuerKey: PrivateKey,
204+
issuerCert: X509Certificate?,
205+
sanDomains: List<String>,
206+
isCa: Boolean = false,
207+
validityDays: Long = 3650,
208+
): X509Certificate {
209+
val now = Instant.now().minus(1, ChronoUnit.DAYS)
210+
val builder = JcaX509v3CertificateBuilder(
211+
issuer,
212+
BigInteger(160, SecureRandom()),
213+
Date.from(now),
214+
Date.from(now.plus(validityDays, ChronoUnit.DAYS)),
215+
subject,
216+
subjectKeyPair.public,
217+
)
218+
val extensionUtils = JcaX509ExtensionUtils()
219+
builder.addExtension(Extension.subjectKeyIdentifier, false, extensionUtils.createSubjectKeyIdentifier(subjectKeyPair.public))
220+
issuerCert?.let {
221+
builder.addExtension(Extension.authorityKeyIdentifier, false, extensionUtils.createAuthorityKeyIdentifier(it.publicKey))
222+
}
223+
if (sanDomains.isNotEmpty()) {
224+
builder.addExtension(
225+
Extension.subjectAlternativeName,
226+
false,
227+
GeneralNames(sanDomains.map { GeneralName(GeneralName.dNSName, it) }.toTypedArray()),
228+
)
229+
}
230+
if (isCa) {
231+
builder.addExtension(Extension.basicConstraints, true, BasicConstraints(true))
232+
}
233+
val holder = builder.build(JcaContentSignerBuilder("SHA256withRSA").build(issuerKey))
234+
return JcaX509CertificateConverter()
235+
.setProvider(BouncyCastleProvider.PROVIDER_NAME)
236+
.getCertificate(holder)
237+
.also { it.verify(if (issuerCert == null) subjectKeyPair.public else issuerCert.publicKey) }
238+
}
239+
240+
internal fun rsaKeyPair(): KeyPair =
241+
KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
242+
243+
internal fun readCertificate(path: Path): X509Certificate =
244+
PEMParser(StringReader(Files.readString(path))).use { parser ->
245+
val value = parser.readObject()
246+
require(value is X509CertificateHolder) { "Expected an X.509 PEM certificate in $path" }
247+
JcaX509CertificateConverter()
248+
.setProvider(BouncyCastleProvider.PROVIDER_NAME)
249+
.getCertificate(value)
250+
}
251+
252+
internal fun readPrivateKey(path: Path): PrivateKey =
253+
PEMParser(StringReader(Files.readString(path))).use { parser ->
254+
val value = parser.readObject()
255+
val converter = JcaPEMKeyConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME)
256+
when (value) {
257+
is PEMKeyPair -> converter.getKeyPair(value).private
258+
is org.bouncycastle.asn1.pkcs.PrivateKeyInfo -> converter.getPrivateKey(value)
259+
else -> error("Expected a PEM private key in $path")
260+
}
261+
}
262+
263+
internal fun writePem(path: Path, value: Any) {
264+
Files.writeString(path, toPem(value), StandardCharsets.UTF_8)
265+
}
266+
267+
internal fun toPem(value: Any): String =
268+
StringWriter().use { buffer ->
269+
JcaPEMWriter(buffer).use { writer -> writer.writeObject(value) }
270+
buffer.toString()
271+
}
272+
}

0 commit comments

Comments
 (0)