Skip to content

Commit 9929b71

Browse files
committed
feat: Add PemSslContext for loading PEM cert + RSA key
1 parent e5f50be commit 9929b71

3 files changed

Lines changed: 116 additions & 5 deletions

File tree

docs/superpowers/plans/2026-05-21-https-support.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ SKIP=commitlint git commit -m "test: Add TLS PEM fixtures for HTTPS support"
166166
- Create: `src/main/java/com/retailsvc/http/internal/PemSslContext.java`
167167
- Create: `src/test/java/com/retailsvc/http/internal/PemSslContextTest.java`
168168

169-
- [ ] **Step 1: Write the failing RSA happy-path test**
169+
- [x] **Step 1: Write the failing RSA happy-path test**
170170

171171
Write `src/test/java/com/retailsvc/http/internal/PemSslContextTest.java`:
172172

@@ -195,15 +195,15 @@ class PemSslContextTest {
195195
}
196196
```
197197

198-
- [ ] **Step 2: Run the test, confirm it fails**
198+
- [x] **Step 2: Run the test, confirm it fails**
199199

200200
```bash
201201
mvn test -Dtest=PemSslContextTest
202202
```
203203

204204
Expected: compilation failure — `PemSslContext` does not exist.
205205

206-
- [ ] **Step 3: Create the minimal `PemSslContext`**
206+
- [x] **Step 3: Create the minimal `PemSslContext`**
207207

208208
Write `src/main/java/com/retailsvc/http/internal/PemSslContext.java`:
209209

@@ -298,15 +298,15 @@ public final class PemSslContext {
298298
}
299299
```
300300

301-
- [ ] **Step 4: Run the test, confirm it passes**
301+
- [x] **Step 4: Run the test, confirm it passes**
302302

303303
```bash
304304
mvn test -Dtest=PemSslContextTest
305305
```
306306

307307
Expected: 1 test passes.
308308

309-
- [ ] **Step 5: Commit**
309+
- [x] **Step 5: Commit**
310310

311311
```bash
312312
git add src/main/java/com/retailsvc/http/internal/PemSslContext.java \
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.retailsvc.http.internal;
2+
3+
import java.io.ByteArrayInputStream;
4+
import java.io.IOException;
5+
import java.nio.file.Files;
6+
import java.nio.file.Path;
7+
import java.security.GeneralSecurityException;
8+
import java.security.KeyFactory;
9+
import java.security.KeyStore;
10+
import java.security.PrivateKey;
11+
import java.security.cert.Certificate;
12+
import java.security.cert.CertificateFactory;
13+
import java.security.spec.InvalidKeySpecException;
14+
import java.security.spec.PKCS8EncodedKeySpec;
15+
import java.util.Base64;
16+
import java.util.Collection;
17+
import javax.net.ssl.KeyManagerFactory;
18+
import javax.net.ssl.SSLContext;
19+
20+
/** Loads a {@link SSLContext} from a PEM certificate chain and PEM PKCS#8 private key. */
21+
public final class PemSslContext {
22+
23+
private PemSslContext() {}
24+
25+
public static SSLContext load(Path certChainPem, Path privateKeyPem) {
26+
Certificate[] chain = readCertificateChain(certChainPem);
27+
PrivateKey key = readPrivateKey(privateKeyPem);
28+
return buildSslContext(chain, key);
29+
}
30+
31+
private static Certificate[] readCertificateChain(Path path) {
32+
byte[] bytes;
33+
try {
34+
bytes = Files.readAllBytes(path);
35+
} catch (IOException e) {
36+
throw new IllegalStateException("Cannot read TLS certificate chain: " + path, e);
37+
}
38+
try {
39+
CertificateFactory factory = CertificateFactory.getInstance("X.509");
40+
Collection<? extends Certificate> certs =
41+
factory.generateCertificates(new ByteArrayInputStream(bytes));
42+
return certs.toArray(new Certificate[0]);
43+
} catch (GeneralSecurityException e) {
44+
throw new IllegalStateException("Failed to parse TLS certificate chain from " + path, e);
45+
}
46+
}
47+
48+
private static PrivateKey readPrivateKey(Path path) {
49+
String pem;
50+
try {
51+
pem = Files.readString(path);
52+
} catch (IOException e) {
53+
throw new IllegalStateException("Cannot read TLS private key: " + path, e);
54+
}
55+
byte[] der;
56+
try {
57+
String base64 =
58+
pem.replace("-----BEGIN PRIVATE KEY-----", "")
59+
.replace("-----END PRIVATE KEY-----", "")
60+
.replaceAll("\\s+", "");
61+
der = Base64.getDecoder().decode(base64);
62+
} catch (IllegalArgumentException e) {
63+
throw new IllegalStateException("Failed to parse TLS private key from " + path, e);
64+
}
65+
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der);
66+
try {
67+
return KeyFactory.getInstance("RSA").generatePrivate(spec);
68+
} catch (InvalidKeySpecException rsaFail) {
69+
throw new IllegalStateException("Failed to parse TLS private key from " + path, rsaFail);
70+
} catch (GeneralSecurityException e) {
71+
throw new IllegalStateException("Failed to parse TLS private key from " + path, e);
72+
}
73+
}
74+
75+
private static SSLContext buildSslContext(Certificate[] chain, PrivateKey key) {
76+
try {
77+
KeyStore ks = KeyStore.getInstance("PKCS12");
78+
ks.load(null, null);
79+
ks.setKeyEntry("server", key, new char[0], chain);
80+
KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509");
81+
kmf.init(ks, new char[0]);
82+
SSLContext ctx = SSLContext.getInstance("TLS");
83+
ctx.init(kmf.getKeyManagers(), null, null);
84+
return ctx;
85+
} catch (GeneralSecurityException | IOException e) {
86+
throw new IllegalStateException("TLS certificate and private key do not match", e);
87+
}
88+
}
89+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.retailsvc.http.internal;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.nio.file.Path;
6+
import javax.net.ssl.SSLContext;
7+
import org.junit.jupiter.api.Test;
8+
9+
class PemSslContextTest {
10+
11+
private static final Path RSA_CERT = Path.of("src/test/resources/tls/rsa-cert.pem");
12+
private static final Path RSA_KEY = Path.of("src/test/resources/tls/rsa-key.pem");
13+
14+
@Test
15+
void loadsRsaPemPair() throws Exception {
16+
SSLContext context = PemSslContext.load(RSA_CERT, RSA_KEY);
17+
18+
assertThat(context).isNotNull();
19+
assertThat(context.getProtocol()).isEqualTo("TLS");
20+
assertThat(context.getServerSocketFactory()).isNotNull();
21+
}
22+
}

0 commit comments

Comments
 (0)