Skip to content

Commit 5bf029e

Browse files
committed
feat: full MitM support with dynamic certificate generation
1 parent 8cc4475 commit 5bf029e

16 files changed

Lines changed: 834 additions & 500 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,5 @@ test-recordings/
5757
*.war
5858
*.ear
5959
*.class
60+
61+
tproxy-ca.*

README.md

Lines changed: 99 additions & 272 deletions
Large diffs are not rendered by default.

src/main/java/org/codejive/tproxy/CertificateAuthority.java

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.io.*;
44
import java.math.BigInteger;
5+
import java.nio.file.Path;
56
import java.security.*;
67
import java.security.cert.Certificate;
78
import java.security.cert.CertificateException;
@@ -25,14 +26,15 @@
2526
* Certificate Authority for generating SSL certificates on-the-fly for HTTPS interception.
2627
*
2728
* <p>This class manages a root CA certificate and generates host-specific certificates signed by
28-
* that CA. The root CA certificate is stored in the current directory and reused across restarts.
29+
* that CA. The root CA certificate is stored in a configurable directory and reused across
30+
* restarts.
2931
*/
3032
public class CertificateAuthority {
3133
private static final Logger logger = LoggerFactory.getLogger(CertificateAuthority.class);
3234

3335
private static final String CA_ALIAS = "tproxy-ca";
34-
private static final String CA_KEYSTORE_FILE = "tproxy-ca.p12";
35-
private static final String CA_CERT_FILE = "tproxy-ca.crt";
36+
private static final String CA_KEYSTORE_FILENAME = "tproxy-ca.p12";
37+
private static final String CA_CERT_FILENAME = "tproxy-ca.crt";
3638
private static final char[] KEYSTORE_PASSWORD = "changeit".toCharArray();
3739
private static final String SIGNATURE_ALGORITHM = "SHA256withRSA";
3840
private static final int KEY_SIZE = 2048;
@@ -50,17 +52,30 @@ public class CertificateAuthority {
5052
private final PrivateKey caPrivateKey;
5153

5254
/**
53-
* Create a new Certificate Authority. If a CA keystore exists in the current directory, it will
54-
* be loaded. Otherwise, a new CA certificate will be generated.
55+
* Create a new Certificate Authority using the current directory for storage. If a CA keystore
56+
* exists, it will be loaded. Otherwise, a new CA certificate will be generated.
5557
*
5658
* @throws IOException if there is an error loading or creating the CA
5759
*/
5860
public CertificateAuthority() throws IOException {
61+
this(Path.of("."));
62+
}
63+
64+
/**
65+
* Create a new Certificate Authority using the specified directory for storage. If a CA
66+
* keystore exists in the directory, it will be loaded. Otherwise, a new CA certificate will be
67+
* generated.
68+
*
69+
* @param storageDir the directory in which to store the CA keystore and certificate files
70+
* @throws IOException if there is an error loading or creating the CA
71+
*/
72+
public CertificateAuthority(Path storageDir) throws IOException {
5973
try {
60-
File keystoreFile = new File(CA_KEYSTORE_FILE);
74+
File keystoreFile = storageDir.resolve(CA_KEYSTORE_FILENAME).toFile();
75+
File certFile = storageDir.resolve(CA_CERT_FILENAME).toFile();
6176

6277
if (keystoreFile.exists()) {
63-
logger.info("Loading existing CA from {}", CA_KEYSTORE_FILE);
78+
logger.info("Loading existing CA from {}", keystoreFile);
6479
caKeyStore = loadKeyStore(keystoreFile);
6580
} else {
6681
logger.info("Generating new CA certificate");
@@ -77,8 +92,8 @@ public CertificateAuthority() throws IOException {
7792
}
7893

7994
// Export CA certificate if it was just generated
80-
if (!keystoreFile.exists() || !new File(CA_CERT_FILE).exists()) {
81-
exportCACertificate(caCertificate);
95+
if (!certFile.exists()) {
96+
exportCACertificate(caCertificate, certFile);
8297
}
8398

8499
logger.info("CA initialized: {}", caCertificate.getSubjectX500Principal());
@@ -177,7 +192,7 @@ public KeyStore generateServerCertificate(String hostname) throws IOException {
177192
*
178193
* @return the CA certificate
179194
*/
180-
public X509Certificate getCACertificate() {
195+
public X509Certificate caCertificate() {
181196
return caCertificate;
182197
}
183198

@@ -260,8 +275,7 @@ private void saveKeyStore(KeyStore keyStore, File file) throws Exception {
260275
}
261276

262277
/** Export CA certificate to PEM file for easy import into browsers. */
263-
private void exportCACertificate(X509Certificate cert) throws Exception {
264-
File certFile = new File(CA_CERT_FILE);
278+
private void exportCACertificate(X509Certificate cert, File certFile) throws Exception {
265279
try (FileWriter fw = new FileWriter(certFile)) {
266280
fw.write("-----BEGIN CERTIFICATE-----\n");
267281
fw.write(
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package org.codejive.tproxy;
2+
3+
import java.io.IOException;
4+
import java.net.Socket;
5+
import java.security.*;
6+
import java.security.cert.X509Certificate;
7+
import java.util.List;
8+
import java.util.concurrent.ConcurrentHashMap;
9+
import javax.net.ssl.*;
10+
import org.slf4j.Logger;
11+
import org.slf4j.LoggerFactory;
12+
13+
/**
14+
* X509ExtendedKeyManager that generates server certificates on-the-fly based on the SNI hostname
15+
* from the client's TLS ClientHello.
16+
*
17+
* <p>When a TLS handshake begins, this key manager extracts the requested hostname from the SNI
18+
* extension, generates a certificate for that hostname using the provided CertificateAuthority, and
19+
* returns it for the handshake. Generated certificates are cached for reuse.
20+
*/
21+
class CertificateGeneratingKeyManager extends X509ExtendedKeyManager {
22+
private static final Logger logger =
23+
LoggerFactory.getLogger(CertificateGeneratingKeyManager.class);
24+
private static final char[] PASSWORD = "changeit".toCharArray();
25+
26+
private final CertificateAuthority certificateAuthority;
27+
private final ConcurrentHashMap<String, KeyStore> certificateCache = new ConcurrentHashMap<>();
28+
29+
CertificateGeneratingKeyManager(CertificateAuthority certificateAuthority) {
30+
this.certificateAuthority = certificateAuthority;
31+
}
32+
33+
@Override
34+
public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
35+
if (engine == null) return null;
36+
try {
37+
SSLSession session = engine.getHandshakeSession();
38+
if (session instanceof ExtendedSSLSession extSession) {
39+
List<SNIServerName> serverNames = extSession.getRequestedServerNames();
40+
for (SNIServerName name : serverNames) {
41+
if (name instanceof SNIHostName hostName) {
42+
String hostname = hostName.getAsciiName();
43+
ensureCertificateExists(hostname);
44+
return hostname;
45+
}
46+
}
47+
}
48+
} catch (Exception e) {
49+
logger.error("Error choosing server alias from SNI", e);
50+
}
51+
return null;
52+
}
53+
54+
@Override
55+
public PrivateKey getPrivateKey(String alias) {
56+
KeyStore ks = certificateCache.get(alias);
57+
if (ks != null) {
58+
try {
59+
return (PrivateKey) ks.getKey(alias, PASSWORD);
60+
} catch (GeneralSecurityException e) {
61+
logger.error("Error getting private key for alias: {}", alias, e);
62+
}
63+
}
64+
return null;
65+
}
66+
67+
@Override
68+
public X509Certificate[] getCertificateChain(String alias) {
69+
KeyStore ks = certificateCache.get(alias);
70+
if (ks != null) {
71+
try {
72+
java.security.cert.Certificate[] chain = ks.getCertificateChain(alias);
73+
if (chain != null) {
74+
X509Certificate[] x509Chain = new X509Certificate[chain.length];
75+
for (int i = 0; i < chain.length; i++) {
76+
x509Chain[i] = (X509Certificate) chain[i];
77+
}
78+
return x509Chain;
79+
}
80+
} catch (KeyStoreException e) {
81+
logger.error("Error getting certificate chain for alias: {}", alias, e);
82+
}
83+
}
84+
return null;
85+
}
86+
87+
private void ensureCertificateExists(String hostname) {
88+
certificateCache.computeIfAbsent(
89+
hostname,
90+
h -> {
91+
try {
92+
logger.debug("Generating certificate for: {}", h);
93+
return certificateAuthority.generateServerCertificate(h);
94+
} catch (IOException e) {
95+
throw new RuntimeException("Failed to generate certificate for " + h, e);
96+
}
97+
});
98+
}
99+
100+
// Server-side only — client methods are unused
101+
@Override
102+
public String[] getClientAliases(String keyType, Principal[] issuers) {
103+
return null;
104+
}
105+
106+
@Override
107+
public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) {
108+
return null;
109+
}
110+
111+
@Override
112+
public String[] getServerAliases(String keyType, Principal[] issuers) {
113+
return null;
114+
}
115+
116+
@Override
117+
public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
118+
return null;
119+
}
120+
}

src/main/java/org/codejive/tproxy/HeaderFilter.java

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -70,26 +70,4 @@ public static Headers filterForForwarding(Headers headers) {
7070

7171
return Headers.of(filtered);
7272
}
73-
74-
/**
75-
* Check if a header should be filtered (removed) when forwarding.
76-
*
77-
* @param headerName the header name (case-insensitive)
78-
* @return true if the header should be removed, false otherwise
79-
*/
80-
public static boolean shouldFilter(String headerName) {
81-
String nameLower = headerName.toLowerCase(Locale.ROOT);
82-
return HOP_BY_HOP_HEADERS.contains(nameLower) || PROXY_SPECIFIC_HEADERS.contains(nameLower);
83-
}
84-
85-
/**
86-
* Add or update the Host header based on the target URI.
87-
*
88-
* @param headers the current headers
89-
* @param host the target host (e.g., "example.com:443")
90-
* @return headers with updated Host header
91-
*/
92-
public static Headers withHost(Headers headers, String host) {
93-
return headers.with("Host", host);
94-
}
9573
}

src/main/java/org/codejive/tproxy/Headers.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public static Headers of(String n1, String v1, String n2, String v2) {
5454
*
5555
* @return the values, or an empty list if the header is not present
5656
*/
57-
public List<String> getAll(String name) {
57+
public List<String> all(String name) {
5858
List<String> values = map.get(name);
5959
return values != null ? values : List.of();
6060
}
@@ -64,7 +64,7 @@ public List<String> getAll(String name) {
6464
*
6565
* @return the first value, or {@code null} if the header is not present
6666
*/
67-
public String getFirst(String name) {
67+
public String first(String name) {
6868
List<String> values = map.get(name);
6969
return (values != null && !values.isEmpty()) ? values.get(0) : null;
7070
}

0 commit comments

Comments
 (0)