AAA‐RE ZScaler SSL TLS Fix — Spring Boot (Java 8, Windows) - Yash-777/MyWorld GitHub Wiki
- Why this happens
- Why Windows fails but Linux/macOS does not
- Fix 1 — Manual browser export + keytool import
- Fix 2 — Java SSL config class (local profile only)
- Fix 3 — Wire RestTemplate correctly (no @Qualifier needed)
- Common mistakes
- Quick reference
ZScaler acts as a TLS man-in-the-middle proxy. Every outbound HTTPS request your application makes is intercepted by ZScaler, which decrypts it, inspects it, then re-encrypts it using its own root Certificate Authority (CA) before forwarding it to the real server.
Your App → ZScaler Proxy → Remote Server
↑
Re-signs TLS with ZScaler Root CA
The JVM's built-in truststore (cacerts) does not know about ZScaler's root CA. When your application performs a TLS handshake, the JVM validates the certificate chain and cannot build a path to a trusted root — so it throws:
PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException:
unable to find valid certification path to requested target
The fix in every case is the same: make the JVM trust ZScaler's root CA.
This is the most common source of confusion. The answer is that ZScaler injects its root CA into the OS certificate store, and the JVM only reads that store on some platforms.
| Platform | JVM reads OS cert store? | Result |
|---|---|---|
| Windows | ❌ No — JVM uses its own cacerts only | ZScaler CA not trusted → PKIX error |
| macOS | ✅ Yes — JVM reads macOS Keychain | ZScaler CA already installed there → works |
| Linux | ✅ Yes (on most distros with ca-certificates) | ZScaler CA in system trust bundle → works |
Problem: PKIX path building failed on Windows after ZScaler install
Root cause: JVM does not read Windows Certificate Store; ZScaler CA not in JVM cacerts
Why Mac/Linux work: JVM reads macOS Keychain / Linux ca-certificates where ZScaler CA is installed
Fix 1 (manual):
- Export cert from browser → Base-64 .cer file
- keytool -import -alias <unique-alias> -keystore combined.jks -file cert.cer -storepass <pw> -noprompt
- Repeat with unique alias for every failing domain
- Point RestTemplate at the combined.jks via SSLContext
Fix 2 (automatic):
- Add SSLAutoTrustConfig.java with @Profile("local")
- Add ssl.domains=... to application-local.properties
- Remove plain @Bean RestTemplate from main application class
- Restart — certs are fetched, exported, and wired automatically
Fix 3 (injection):
- One @Bean RestTemplate only (the SSL-aware one)
- Plain @Autowired RestTemplate — no @Qualifier needed
- Never use new RestTemplate() in static util classes — pass the bean as a parameter
- [Why this happens](#why-this-happens)
- [Why Windows fails but Linux/macOS does not](#why-windows-fails-but-linuxmacos-does-not)
- [Fix 1 — Manual browser export + keytool import](#fix-1--manual-browser-export--keytool-import)
- [Fix 2 — Java SSL config class (local profile only)](#fix-2--java-ssl-config-class-local-profile-only)
- [Fix 3 — Wire RestTemplate correctly (no @Qualifier needed)](#fix-3--wire-resttemplate-correctly-no-qualifier-needed)
- [Common mistakes](#common-mistakes)
- [Quick reference](#quick-reference)
ZScaler acts as a TLS man-in-the-middle proxy. Every outbound HTTPS request your application makes is intercepted by ZScaler, which decrypts it, inspects it, then re-encrypts it using its own root Certificate Authority (CA) before forwarding it to the real server.
Your App → ZScaler Proxy → Remote Server
↑
Re-signs TLS with ZScaler Root CA
The JVM's built-in truststore (cacerts) does not know about ZScaler's root CA. When your application performs a TLS handshake, the JVM validates the certificate chain and cannot build a path to a trusted root — so it throws:
PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException:
unable to find valid certification path to requested target
The fix in every case is the same: make the JVM trust ZScaler's root CA.
This is the most common source of confusion. The answer is that ZScaler injects its root CA into the OS certificate store, and the JVM only reads that store on some platforms.
| Platform | JVM reads OS cert store? | Result |
|---|---|---|
| Windows | ❌ No — JVM uses its own cacerts only |
ZScaler CA not trusted → PKIX error |
| macOS | ✅ Yes — JVM reads macOS Keychain | ZScaler CA already installed there → works |
| Linux | ✅ Yes (on most distros with ca-certificates) |
ZScaler CA in system trust bundle → works |
When your IT team installs ZScaler on macOS or Linux, they install the root CA into the OS trust store. The JVM on those platforms picks it up automatically. On Windows the JVM ignores the Windows Certificate Store entirely and only trusts what is in $JAVA_HOME/jre/lib/security/cacerts — which does not include ZScaler.
Short answer: macOS and Linux work because the JVM reads the OS trust store there. Windows does not work because the JVM ignores the Windows Certificate Store.
This approach exports the ZScaler certificate from the browser and imports it into a Java keystore (.jks) file, which your application then uses.
- Open Chrome or Edge.
- Navigate to one of the failing HTTPS URLs (e.g.
https://app.uat-opt.idfcfirstbank.com). - Click the lock icon in the address bar.
- Click Connection is secure → Certificate is valid.
- Go to the Details tab.
- Click Export (Chrome) or Copy to File (Edge).
- Choose format: Base-64 encoded X.509 (.CER).
- Save the file, e.g.
zscaler-root.cer.
Repeat for every domain that is failing. Each domain may present a different ZScaler-signed certificate depending on your ZScaler policy.
Open Command Prompt and run one keytool command per certificate. Each certificate gets its own alias so they do not conflict.
REM Domain 1
keytool -import ^
-alias zscaler-domain1 ^
-keystore "combined-truststore.jks" ^
-file "domain1-cert.cer" ^
-storepass "yourPassword" ^
-noprompt
REM Domain 2 (different alias — alias must be unique per keystore)
keytool -import ^
-alias zscaler-domain2 ^
-keystore "combined-truststore.jks" ^
-file "domain2-cert.cer" ^
-storepass "yourPassword" ^
-noprompt
REM Domain 3
keytool -import ^
-alias zscaler-domain3 ^
-keystore "combined-truststore.jks" ^
-file "domain3-cert.cer" ^
-storepass "yourPassword" ^
-noprompt
⚠️ Use a different-aliasfor every import. If you reuse the same alias (e.g.zscaler-root) you will get:keytool error: java.lang.Exception: Certificate not imported, alias <zscaler-root> already existsThe first import succeeds; every subsequent one fails silently.
keytool -list -keystore "combined-truststore.jks" -storepass "yourPassword"You should see one entry per alias you imported.
Copy the generated combined-truststore.jks into your project, for example under src/main/resources/certs/ or a local directory like exportedcerts/. Do not commit it to source control — add that directory to .gitignore.
exportedcerts/
Instead of manually running keytool commands, this class automatically fetches the certificate from the live server at startup, imports it, and builds the truststore in memory. It also exports .cer and .jks files for audit purposes.
This class is gated to the local Spring profile so it never runs in UAT or production environments.
spring.profiles.active=local
# Comma-separated domain hostnames — no https://, no trailing slash
# For a custom port append :port, e.g. internal-service.local:8443
# For standard HTTPS (port 443), just use the hostname
ssl.domains=app.uat-opt.idfcfirstbank.com,api-uat2.yourdomain.compackage com.example.config;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.ssl.SSLContextBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import javax.net.ssl.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.*;
import java.security.cert.*;
import java.util.*;
/**
* SSLAutoTrustConfig — local profile only.
*
* On Windows, ZScaler intercepts TLS and re-signs traffic with its own root CA.
* The JVM does not read the Windows Certificate Store, so it cannot verify the
* ZScaler-signed certificate, causing PKIX path building failed errors.
*
* This class resolves the issue automatically on startup:
* 1. For each domain in ssl.domains, it opens a trust-all TLS socket (read-only)
* to capture the full certificate chain [leaf → intermediate → root].
* 2. It compares each cert to the previously exported .cer file on disk:
* - No file found → write .cer and import into KeyStore [NEW]
* - Same byte size → skip, cert has not changed [SKIP]
* - Different size → overwrite .cer and replace in KeyStore [UPDATE]
* 3. It exports a combined-truststore.jks to exportedcerts/ for audit/debug.
* 4. It builds a single RestTemplate bean backed by the combined SSLContext.
*
* Port resolution:
* "app.example.com" → port 443 (default HTTPS, public domain)
* "internal.local:8443" → port 8443 (custom port, internal domain)
*
* Cert file naming (dots preserved for traceability):
* exportedcerts/app.example.com-cert-0.cer ← leaf cert
* exportedcerts/app.example.com-cert-1.cer ← intermediate CA
* exportedcerts/app.example.com-cert-2.cer ← ZScaler root CA
* exportedcerts/combined-truststore.jks
*/
@Profile("local")
@Configuration
public class SSLAutoTrustConfig {
private static final Logger logger = LoggerFactory.getLogger(SSLAutoTrustConfig.class);
/**
* Hardcoded password for the generated JKS truststore.
* This is local/dev only — no production exposure.
*/
private static final String TRUSTSTORE_PASSWORD = "localDev@2025";
/**
* Output folder for .cer and .jks files.
* Resolves to {project root}/exportedcerts/ when running from the IDE,
* or {jar directory}/exportedcerts/ when running as a JAR.
*/
private static final String EXPORT_DIR = "exportedcerts";
/** Default HTTPS port for public domains with no explicit :port suffix */
private static final int DEFAULT_HTTPS_PORT = 443;
/** Configured in application-local.properties */
@Value("${ssl.domains}")
private String sslDomains;
/**
* Single RestTemplate bean backed by the auto-built truststore.
* This is the only RestTemplate bean in the application context when
* running on the local profile — no @Qualifier is needed anywhere.
*/
@Bean
public RestTemplate restTemplate() throws Exception {
List<String> entries = parseDomains(sslDomains);
logger.info("SSLAutoTrustConfig : [local] processing {} domain(s): {}", entries.size(), entries);
KeyStore trustStore = buildCombinedTrustStore(entries);
SSLContext sslContext = SSLContextBuilder.create()
.loadTrustMaterial(trustStore, null)
.build();
CloseableHttpClient httpClient = HttpClients.custom()
.setSSLContext(sslContext)
.build();
logger.info("SSLAutoTrustConfig : [local] RestTemplate ready. Certs → {}/",
Paths.get(EXPORT_DIR).toAbsolutePath());
return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient));
}
private KeyStore buildCombinedTrustStore(List<String> entries) throws Exception {
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null, TRUSTSTORE_PASSWORD.toCharArray());
Files.createDirectories(Paths.get(EXPORT_DIR));
for (String entry : entries) {
String host = resolveHost(entry);
int port = resolvePort(entry);
logger.info("SSLAutoTrustConfig : fetching certs from {}:{}", host, port);
List<X509Certificate> chain = fetchCertChain(host, port);
if (chain.isEmpty()) {
logger.warn("SSLAutoTrustConfig : no certs retrieved for {} — skipping", host);
continue;
}
syncCertsAndKeyStore(keyStore, host, chain);
}
persistKeyStore(keyStore);
return keyStore;
}
private String resolveHost(String entry) {
return entry.contains(":") ? entry.split(":")[0].trim() : entry.trim();
}
private int resolvePort(String entry) {
if (entry.contains(":")) {
try {
return Integer.parseInt(entry.split(":")[1].trim());
} catch (NumberFormatException e) {
logger.warn("SSLAutoTrustConfig : invalid port in '{}' — using {}", entry, DEFAULT_HTTPS_PORT);
}
}
return DEFAULT_HTTPS_PORT;
}
private List<X509Certificate> fetchCertChain(String host, int port) {
List<X509Certificate> chain = new ArrayList<X509Certificate>();
try {
SSLContext trustAllCtx = SSLContext.getInstance("TLS");
trustAllCtx.init(null, new TrustManager[]{new X509TrustManager() {
private X509Certificate[] captured;
public void checkClientTrusted(X509Certificate[] c, String a) {}
public void checkServerTrusted(X509Certificate[] c, String a) { this.captured = c; }
public X509Certificate[] getAcceptedIssuers() {
return captured != null ? captured : new X509Certificate[0];
}
}}, new SecureRandom());
try (SSLSocket socket = (SSLSocket) trustAllCtx.getSocketFactory().createSocket(host, port)) {
socket.setSoTimeout(10_000);
socket.startHandshake();
for (Certificate cert : socket.getSession().getPeerCertificates()) {
if (cert instanceof X509Certificate) {
chain.add((X509Certificate) cert);
}
}
}
logger.info("SSLAutoTrustConfig : fetched {} cert(s) from {}:{}", chain.size(), host, port);
} catch (Exception e) {
logger.error("SSLAutoTrustConfig : failed to fetch from {}:{} — {}", host, port, e.getMessage());
}
return chain;
}
private void syncCertsAndKeyStore(KeyStore keyStore, String host, List<X509Certificate> chain) {
for (int i = 0; i < chain.size(); i++) {
X509Certificate liveCert = chain.get(i);
String fileName = host + "-cert-" + i + ".cer";
Path filePath = Paths.get(EXPORT_DIR, fileName);
String alias = host.replace(".", "-") + "-" + i;
try {
byte[] liveBytes = liveCert.getEncoded();
if (!Files.exists(filePath)) {
logger.info("SSLAutoTrustConfig : [NEW] {} — writing and importing", fileName);
writeCertFile(filePath, liveBytes);
addToKeyStore(keyStore, alias, liveCert);
} else {
long diskSize = Files.size(filePath);
long liveSize = liveBytes.length;
if (diskSize == liveSize) {
logger.info("SSLAutoTrustConfig : [SKIP] {} — cert unchanged ({}B)", fileName, diskSize);
} else {
logger.info("SSLAutoTrustConfig : [UPDATE] {} — disk={}B live={}B, replacing",
fileName, diskSize, liveSize);
writeCertFile(filePath, liveBytes);
removeFromKeyStore(keyStore, alias);
addToKeyStore(keyStore, alias, liveCert);
}
}
} catch (Exception e) {
logger.error("SSLAutoTrustConfig : error syncing cert[{}] for {} — {}", i, host, e.getMessage());
}
}
}
private void writeCertFile(Path path, byte[] encoded) throws IOException {
String pem = "-----BEGIN CERTIFICATE-----\n"
+ Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(encoded)
+ "\n-----END CERTIFICATE-----\n";
Files.write(path, pem.getBytes(StandardCharsets.UTF_8));
logger.info("SSLAutoTrustConfig : wrote → {}", path.toAbsolutePath());
}
private void addToKeyStore(KeyStore ks, String alias, X509Certificate cert) throws KeyStoreException {
if (!ks.containsAlias(alias)) {
ks.setCertificateEntry(alias, cert);
logger.info("SSLAutoTrustConfig : keystore ← added '{}'", alias);
}
}
private void removeFromKeyStore(KeyStore ks, String alias) throws KeyStoreException {
if (ks.containsAlias(alias)) {
ks.deleteEntry(alias);
logger.info("SSLAutoTrustConfig : keystore ✕ removed old '{}'", alias);
}
}
private void persistKeyStore(KeyStore ks) {
Path jksPath = Paths.get(EXPORT_DIR, "combined-truststore.jks");
try (FileOutputStream fos = new FileOutputStream(jksPath.toFile())) {
ks.store(fos, TRUSTSTORE_PASSWORD.toCharArray());
logger.info("SSLAutoTrustConfig : exported → {}", jksPath.toAbsolutePath());
} catch (Exception e) {
logger.error("SSLAutoTrustConfig : failed to export JKS — {}", e.getMessage());
}
}
private List<String> parseDomains(String raw) {
List<String> result = new ArrayList<String>();
if (raw == null || raw.trim().isEmpty()) return result;
for (String part : raw.split(",")) {
String trimmed = part.trim();
if (!trimmed.isEmpty()) result.add(trimmed);
}
return result;
}
}exportedcerts/
├── app.uat-opt.idfcfirstbank.com-cert-0.cer ← leaf cert
├── app.uat-opt.idfcfirstbank.com-cert-1.cer ← intermediate CA
├── app.uat-opt.idfcfirstbank.com-cert-2.cer ← ZScaler root CA
├── api-uat2.yourdomain.com-cert-0.cer
├── api-uat2.yourdomain.com-cert-1.cer
└── combined-truststore.jks
Add to .gitignore:
exportedcerts/
No code change needed. Add the hostname to application-local.properties and restart:
ssl.domains=app.uat-opt.idfcfirstbank.com,api-uat2.yourdomain.com,new-domain.comThe most common mistake is having two RestTemplate beans in the application context at the same time:
// In the main application class — creates a plain RestTemplate with no SSL config
@Bean
public RestTemplate restTemplate() {
return new RestTemplate(); // ← this is the DEFAULT bean
}
// In SSLAutoTrustConfig — creates an SSL-aware RestTemplate
@Bean
public RestTemplate restTemplate() throws Exception { ... } // ← this conflictsWhen two beans of the same type exist, Spring cannot decide which one to inject. Services that do not specify @Qualifier pick up the plain one with no SSL config, causing PKIX errors even after the SSL config class is in place.
Remove the plain RestTemplate bean entirely from your main application class. The SSL config class provides the only RestTemplate bean in the context. No @Qualifier is needed anywhere because there is only one bean of that type.
// Main application class — REMOVE the @Bean method entirely
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
// No @Bean RestTemplate here
}// Any service — plain @Autowired works, no @Qualifier needed
@Service
public class ExternalApiService {
@Autowired
private RestTemplate restTemplate; // picks up the SSL-aware bean automatically
}Static helper classes that instantiate their own RestTemplate bypass the SSL context entirely. The fix is to pass the bean in as a parameter from the caller.
// Before — bypasses SSL config
public class TokenValidatorUtil {
public static Boolean validate(String token, String url) {
RestTemplate restTemplate = new RestTemplate(); // ← wrong
...
}
}
// After — caller passes the managed bean
public class TokenValidatorUtil {
public static Boolean validate(String token, String url, RestTemplate restTemplate) {
...
}
}// Caller (a filter or service)
@Autowired
private RestTemplate restTemplate;
// Usage
TokenValidatorUtil.validate(token, url, restTemplate);If you used Fix 1 and have multiple .jks files, do not overwrite the same variable — merge them:
// Wrong — only the last keystore is used; the others are discarded
KeyStore ks = loadKeyStore(path1);
ks = loadKeyStore(path2); // discards path1
ks = loadKeyStore(path3); // discards path2
// Correct — merge all aliases into one combined KeyStore
private KeyStore buildCombined(List<String> paths) throws Exception {
KeyStore combined = KeyStore.getInstance(KeyStore.getDefaultType());
combined.load(null, TRUSTSTORE_PASSWORD.toCharArray());
for (String path : paths) {
KeyStore source = loadKeyStore(path);
Enumeration<String> aliases = source.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
Certificate cert = source.getCertificate(alias);
// Make alias unique across all source keystores
String uniqueAlias = alias + "-" + Math.abs(path.hashCode());
combined.setCertificateEntry(uniqueAlias, cert);
}
}
return combined;
}| Mistake | Symptom | Fix |
|---|---|---|
Same -alias in multiple keytool -import calls |
Certificate not imported, alias already exists |
Use a unique alias per domain e.g. zscaler-domain1, zscaler-domain2
|
Overwriting the KeyStore variable in a loop |
Only the last .jks cert is trusted |
Merge aliases into a single combined KeyStore as shown above |
Two @Bean RestTemplate methods |
Spring injects the wrong (plain) bean | Remove the plain restTemplate() bean from the main class |
@Qualifier still present after removing the plain bean |
Harmless but unnecessary noise | Remove @Qualifier — one bean means no ambiguity |
new RestTemplate() inside a static util class |
PKIX error from that code path only | Pass RestTemplate as a parameter from the caller |
Missing @Profile("local") on the SSL config class |
SSL config runs in all environments | Add @Profile("local") so it only activates locally |
exportedcerts/ committed to source control |
Cert files in repo history | Add exportedcerts/ to .gitignore
|
Problem: PKIX path building failed on Windows after ZScaler install
Root cause: JVM does not read Windows Certificate Store; ZScaler CA not in JVM cacerts
Why Mac/Linux work: JVM reads macOS Keychain / Linux ca-certificates where ZScaler CA is installed
Fix 1 (manual):
1. Export cert from browser → Base-64 .cer file
2. keytool -import -alias <unique-alias> -keystore combined.jks -file cert.cer -storepass <pw> -noprompt
3. Repeat with unique alias for every failing domain
4. Point RestTemplate at the combined.jks via SSLContext
Fix 2 (automatic):
1. Add SSLAutoTrustConfig.java with @Profile("local")
2. Add ssl.domains=... to application-local.properties
3. Remove plain @Bean RestTemplate from main application class
4. Restart — certs are fetched, exported, and wired automatically
Fix 3 (injection):
- One @Bean RestTemplate only (the SSL-aware one)
- Plain @Autowired RestTemplate — no @Qualifier needed
- Never use new RestTemplate() in static util classes — pass the bean as a parameter
package com.royalenfield.finance.config;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.ssl.SSLContextBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import javax.net.ssl.*;
import java.io.*;
import java.nio.file.*;
import java.security.*;
import java.security.cert.*;
import java.util.*;
/**
* IDFCSSLConfig — Dev profile only
*
* Automatically fetches SSL certificates from all configured domain URLs at
* startup, builds a combined JKS truststore in memory, and exposes a single
* RestTemplate bean that trusts all of them.
*
* ── Why this exists ──────────────────────────────────────────────────────────
* ZScaler intercepts TLS and re-signs traffic with its own root CA.
* The JVM's default cacerts doesn't know about ZScaler's CA, causing:
* PKIX path building failed: unable to find valid certification path
* This class fixes it without touching the system JVM or exporting certs
* manually from the browser.
*
* ── Flow per domain ──────────────────────────────────────────────────────────
* 1. resolvePort() → use 443 for public HTTPS; use custom port if
* domain is suffixed with ":port" e.g. "host:8443"
* 2. fetchCertChain() → trust-all TLS socket, reads full chain [leaf→root]
* 3. exportAndSyncCertFiles() → for each cert in chain:
* • no file on disk → write .cer + add to keystore
* • file exists, same size → skip (cert unchanged)
* • file exists, different size → overwrite .cer,
* remove old alias from keystore, add new cert
* 4. exportTrustStoreJks() → persist combined-truststore.jks to exportedcerts/
* 5. idfcRestTemplate() → SSLContext from KeyStore → CloseableHttpClient
* → RestTemplate bean
*
* ── Cert file naming ─────────────────────────────────────────────────────────
* Files are named after the domain URL so it is obvious which host each belongs to:
* exportedcerts/app.uat-opt.idfcfirstbank.com-cert-0.cer ← leaf
* exportedcerts/app.uat-opt.idfcfirstbank.com-cert-1.cer ← intermediate
* exportedcerts/app.uat-opt.idfcfirstbank.com-cert-2.cer ← ZScaler root
* exportedcerts/api-uat2.royalenfield.com-cert-0.cer
* exportedcerts/combined-truststore.jks
*
* ── application-dev.properties ───────────────────────────────────────────────
* # Plain domain → port 443 used automatically (public HTTPS)
* # "host:port" → custom port used (internal / non-public services)
* idfc.ssl.domains=app.uat-opt.idfcfirstbank.com,api-uat2.royalenfield.com,internal.local:8443
*/
@Profile("dev") // Active only when spring.profiles.active=dev
@Configuration
public class IDFCSSLConfig {
private static final Logger logger = LoggerFactory.getLogger(IDFCSSLConfig.class);
/**
* Hardcoded truststore password — dev only.
* Replace with @Value("${idfc.ssl.truststore.password}") + env variable for production.
*/
private static final String TRUSTSTORE_PASSWORD = "re@ZscalerDev2025";
/**
* Export folder: {project working directory}/exportedcerts/
* When running from IDE this resolves to the project root.
* When running as a JAR this resolves to the directory the JAR is launched from.
*/
private static final String EXPORT_DIR = "exportedcerts";
/** Default port for public HTTPS domains that carry no explicit :port suffix */
private static final int DEFAULT_HTTPS_PORT = 443;
/** Comma-separated domain entries from application-dev.properties */
@Value("${idfc.ssl.domains}")
private String sslDomains;
// ── Bean ──────────────────────────────────────────────────────────────────
/**
* Single RestTemplate bean that trusts all configured domain certificates.
* Inject with: @Autowired @Qualifier("idfcRestTemplate") RestTemplate restTemplate;
*/
@Bean(name = "idfcRestTemplate")
public RestTemplate idfcRestTemplate() throws Exception {
List<String> domainEntries = parseDomains(sslDomains);
logger.info("IDFCSSLConfig : [dev] processing {} domain(s): {}", domainEntries.size(), domainEntries);
KeyStore combinedTrustStore = buildCombinedTrustStore(domainEntries);
SSLContext sslContext = SSLContextBuilder.create()
.loadTrustMaterial(combinedTrustStore, null)
.build();
CloseableHttpClient httpClient = HttpClients.custom()
.setSSLContext(sslContext)
.build();
logger.info("IDFCSSLConfig : [dev] idfcRestTemplate ready. Certs → {}/",
Paths.get(EXPORT_DIR).toAbsolutePath());
return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient));
}
// ── Core ──────────────────────────────────────────────────────────────────
/**
* Iterates over all configured domain entries.
* For each: resolves port, fetches live cert chain, syncs .cer files and
* keystore aliases, then persists the final JKS to disk.
*
* @param domainEntries list of "host" or "host:port" strings
* @return fully populated KeyStore ready for SSLContext
*/
private KeyStore buildCombinedTrustStore(List<String> domainEntries) throws Exception {
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); // JKS
keyStore.load(null, TRUSTSTORE_PASSWORD.toCharArray()); // init empty store
Files.createDirectories(Paths.get(EXPORT_DIR)); // ensure folder exists
for (String entry : domainEntries) {
String host = resolveHost(entry);
int port = resolvePort(entry);
logger.info("IDFCSSLConfig : ── domain: {} | port: {}", host, port);
List<X509Certificate> chain = fetchCertChain(host, port);
if (chain.isEmpty()) {
logger.warn("IDFCSSLConfig : no certs retrieved for {} — skipping", host);
continue;
}
exportAndSyncCertFiles(keyStore, host, chain);
}
exportTrustStoreJks(keyStore);
return keyStore;
}
// ── Step 1 — Port resolution ──────────────────────────────────────────────
/**
* Extracts the hostname from a "host" or "host:port" entry.
*
* "app.uat-opt.idfcfirstbank.com" → "app.uat-opt.idfcfirstbank.com"
* "internal-service.local:8443" → "internal-service.local"
*/
private String resolveHost(String entry) {
return entry.contains(":") ? entry.split(":")[0].trim() : entry.trim();
}
/**
* Resolves the TLS port for a domain entry.
*
* Rules:
* • No colon in entry → public domain → use DEFAULT_HTTPS_PORT (443)
* • Has "host:port" → internal/non-public domain → use that port
*
* Examples:
* "app.uat-opt.idfcfirstbank.com" → 443 (public, no suffix)
* "api-uat2.royalenfield.com" → 443 (public, no suffix)
* "internal-service.local:8443" → 8443 (custom port provided)
*
* @param entry raw domain string from idfc.ssl.domains
* @return resolved port number
*/
private int resolvePort(String entry) {
if (entry.contains(":")) {
try {
int port = Integer.parseInt(entry.split(":")[1].trim());
logger.debug("IDFCSSLConfig : resolvePort → custom port {} for '{}'", port, entry);
return port;
} catch (NumberFormatException e) {
logger.warn("IDFCSSLConfig : invalid port in '{}' — falling back to {}", entry, DEFAULT_HTTPS_PORT);
}
}
logger.debug("IDFCSSLConfig : resolvePort → default port {} for '{}'", DEFAULT_HTTPS_PORT, entry);
return DEFAULT_HTTPS_PORT;
}
// ── Step 2 — Fetch live cert chain from server ────────────────────────────
/**
* Opens a trust-all TLS handshake with host:port ONLY to read the server's
* certificate chain. This trust-all context is never reused for real API calls.
*
* @param host resolved hostname
* @param port resolved port
* @return ordered list [leaf cert, intermediate(s), root CA]
*/
private List<X509Certificate> fetchCertChain(String host, int port) {
List<X509Certificate> chain = new ArrayList<>();
try {
SSLContext trustAllCtx = SSLContext.getInstance("TLS");
trustAllCtx.init(null, new TrustManager[]{new X509TrustManager() {
private X509Certificate[] captured;
@Override public void checkClientTrusted(X509Certificate[] c, String a) {}
@Override
public void checkServerTrusted(X509Certificate[] c, String a) {
this.captured = c; // capture what the server sends
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return captured != null ? captured : new X509Certificate[0];
}
}}, new SecureRandom());
try (SSLSocket socket = (SSLSocket) trustAllCtx.getSocketFactory()
.createSocket(host, port)) {
socket.setSoTimeout(10_000);
socket.startHandshake();
for (Certificate cert : socket.getSession().getPeerCertificates()) {
if (cert instanceof X509Certificate) {
chain.add((X509Certificate) cert);
}
}
}
logger.info("IDFCSSLConfig : fetched {} cert(s) from {}:{}", chain.size(), host, port);
} catch (Exception e) {
logger.error("IDFCSSLConfig : failed to fetch cert chain from {}:{} — {}", host, port, e.getMessage());
}
return chain;
}
// ── Step 3 — Export .cer files + sync keystore ────────────────────────────
/**
* For each certificate in the chain, decides whether to add, replace, or skip
* — both the .cer file on disk and the alias in the keystore.
*
* Decision table (evaluated per cert index i):
*
* ┌──────────────────────────────────────┬────────────────────────────────────────────────┐
* │ .cer file state │ Action │
* ├──────────────────────────────────────┼────────────────────────────────────────────────┤
* │ Does not exist │ Write .cer + add alias to keystore [NEW] │
* │ Exists — same byte size as live cert │ Skip file and keystore — cert unchanged [SKIP] │
* │ Exists — different byte size │ Overwrite .cer + remove old alias + add [UPDATE]│
* └──────────────────────────────────────┴────────────────────────────────────────────────┘
*
* Cert file naming uses the domain hostname directly (dots preserved) so the file
* is immediately traceable to its origin URL:
* exportedcerts/app.uat-opt.idfcfirstbank.com-cert-0.cer
*
* Keystore alias uses dots-replaced-with-dashes (JKS alias constraint):
* app-uat-opt-idfcfirstbank-com-0
*
* @param keyStore in-memory JKS to update
* @param host domain hostname
* @param chain live cert chain fetched from the server
*/
private void exportAndSyncCertFiles(KeyStore keyStore,
String host,
List<X509Certificate> chain) {
for (int i = 0; i < chain.size(); i++) {
X509Certificate liveCert = chain.get(i);
// File: dots preserved → clearly maps back to the domain URL
String fileName = host + "-cert-" + i + ".cer";
Path filePath = Paths.get(EXPORT_DIR, fileName);
// Alias: dots replaced → JKS-safe alias
String alias = host.replace(".", "-") + "-" + i;
try {
byte[] liveCertEncoded = liveCert.getEncoded();
if (!Files.exists(filePath)) {
// ── Case 1: No .cer file on disk — first time import ──────
logger.info("IDFCSSLConfig : [NEW] no file found — writing & importing | {}", fileName);
writeCertFile(filePath, liveCertEncoded);
addCertToKeyStore(keyStore, alias, liveCert);
} else {
long diskSize = Files.size(filePath);
long liveSize = liveCertEncoded.length;
if (diskSize == liveSize) {
// ── Case 2: Same byte size — cert has not changed ─────
logger.info("IDFCSSLConfig : [SKIP] cert unchanged ({}B) | {}", diskSize, fileName);
} else {
// ── Case 3: Different byte size — cert was rotated ────
logger.info("IDFCSSLConfig : [UPDATE] cert changed disk={}B live={}B — replacing | {}",
diskSize, liveSize, fileName);
writeCertFile(filePath, liveCertEncoded);
removeCertFromKeyStore(keyStore, alias);
addCertToKeyStore(keyStore, alias, liveCert);
}
}
} catch (Exception e) {
logger.error("IDFCSSLConfig : error syncing cert[{}] for {} — {}", i, host, e.getMessage());
}
}
}
// ── Step 3 helpers ────────────────────────────────────────────────────────
/**
* Writes a DER-encoded certificate as a PEM .cer file to disk.
* Equivalent to: Chrome → lock icon → Certificate → Export as Base64 .cer
*/
private void writeCertFile(Path filePath, byte[] encoded) throws IOException {
String pem = "-----BEGIN CERTIFICATE-----\n"
+ Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(encoded)
+ "\n-----END CERTIFICATE-----\n";
Files.write(filePath, pem.getBytes(java.nio.charset.StandardCharsets.UTF_8)); // Java 8 compatible
logger.info("IDFCSSLConfig : wrote → {}", filePath.toAbsolutePath());
}
/**
* Adds a certificate to the keystore under the given alias.
* Skips silently if alias already present (guard against duplicate adds).
*/
private void addCertToKeyStore(KeyStore keyStore,
String alias,
X509Certificate cert) throws KeyStoreException {
if (!keyStore.containsAlias(alias)) {
keyStore.setCertificateEntry(alias, cert);
logger.info("IDFCSSLConfig : keystore ← added '{}' | subject: {}",
alias, cert.getSubjectDN().getName());
} else {
logger.debug("IDFCSSLConfig : alias '{}' already present — skipping add", alias);
}
}
/**
* Removes a certificate alias from the keystore.
* Called when a cert has changed (different byte size detected on disk).
* Skips silently if alias not present.
*/
private void removeCertFromKeyStore(KeyStore keyStore, String alias) throws KeyStoreException {
if (keyStore.containsAlias(alias)) {
keyStore.deleteEntry(alias);
logger.info("IDFCSSLConfig : keystore ✕ removed old alias '{}'", alias);
} else {
logger.debug("IDFCSSLConfig : alias '{}' not in keystore — nothing to remove", alias);
}
}
// ── Step 4 — Persist JKS ─────────────────────────────────────────────────
/**
* Writes the in-memory KeyStore to disk as a JKS file after all domains
* have been processed. Always overwrites to stay in sync with in-memory state.
*
* Output: exportedcerts/combined-truststore.jks
*
* Equivalent to running:
* keytool -import -alias <alias> -keystore combined-truststore.jks
* -file <cert>.cer -storepass re@ZscalerDev2025 -noprompt
*/
private void exportTrustStoreJks(KeyStore keyStore) {
Path jksPath = Paths.get(EXPORT_DIR, "combined-truststore.jks");
try (FileOutputStream fos = new FileOutputStream(jksPath.toFile())) {
keyStore.store(fos, TRUSTSTORE_PASSWORD.toCharArray());
logger.info("IDFCSSLConfig : exported combined truststore → {}", jksPath.toAbsolutePath());
} catch (Exception e) {
logger.error("IDFCSSLConfig : failed to export truststore JKS — {}", e.getMessage());
}
}
// ── Utility ───────────────────────────────────────────────────────────────
/**
* Splits a comma-separated string of domain entries, trims each, drops blanks.
*
* Input : "app.uat-opt.idfcfirstbank.com, api-uat2.royalenfield.com, internal.local:8443"
* Output: ["app.uat-opt.idfcfirstbank.com", "api-uat2.royalenfield.com", "internal.local:8443"]
*/
private List<String> parseDomains(String raw) {
List<String> result = new ArrayList<>();
if (raw == null || raw.trim().isEmpty()) return result; // Java 8 compatible
for (String part : raw.split(",")) {
String trimmed = part.trim();
if (!trimmed.isEmpty()) result.add(trimmed);
}
return result;
}
}