[Error/Spring/SSL] PKIX path building failed

2025. 5. 31. 22:07Backend/Spring

 

 

향후 다른 개발 중에도 만날 수 있는 에러로서, 블로그 글로 작성하여 내용을 남기려고 합니다~!!

 

Java, Spring Boot 환경을 기준으로 작성되었습니다

 

😭Error

로컬 환경, 테스트 환경, (Staging 혹은) 운영 환경에서 발생할 수 있는 에러인데요!! 특정 날을 기점으로 로컬 환경에서부터 아래의 에러가 발생하기 시작했습니다. 

org.springframework.web.client.ResourceAccessException: 
I/O error on GET request for "https://(도메인 or ip):port/api": 
PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: 
unable to find valid certification path to requested target; 
nested exception is javax.net.ssl.SSLHandshakeException: 
PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: 
unable to find valid certification path to requested target

 

😂원인

 

해당 에러의 원인을 파악하기 전에, 먼저 먼저 TLS/SSL의 통신의 원리를 정리해봤습니다.

TLS/SSL 은 암호화 기반 인터넷 보안 프로토콜입니다. 여기서 TLS는 Transport Layer Security 이며, SSL은 Secure Sockets Layer인데요. 통신할 때 데이터를 암호화, 인증, 무결성을 보장하기 위해 사용합니다. 이 통신의 특징으로는 1) 핸드셰이크(HandShake) 과정을 통해 인증을 먼저 수행하고, 2) 이후에 데이터를 송/수신한다는 것입니다.

데이터 송수신 이전에 인증을 수행하게 되는데요! 예를 들어, 클라이언트가 서버에 특정 api 등으로 요청을 하게 됩니다. 그러면 서버는 공개키(public key) 등이 포함되어 있는 인증서를 클라이언트에 전달해줍니다. 이 때 클라이언트는 해당 인증서가 신뢰할 수 있는지 확인하게 됩니다. 만약 신뢰할 수 있다면 암호화된 대칭키 같은 key를 서버에 전달해줍니다. 이후 서로 통신할 수 있는 '안전한 환경'을 구축한 상태에서, 필요한 데이터 송수신을 하게 됩니다.

 

해당 에러의 포인트는 SSLHandshake 에러가 발생하였다는 부분인데요!! 원인을 알기 위해 PKIX를 찾아보니, PKIX는 Public-Key Infrastructure로서, 공개 키 기반 구조를 말하고 있었습니다. 인증 경로를 구축하고 검증하는 과정 중에 에러가 발생한 것이었는데, 클라이언트가 신뢰할 수 있는 인증서를 찾지 못했기 때문에 발생한 에러였던 겁니다.

 

저의 경우 postman 을 이용한 요청이나 curl을 이용한 요청은 성공을 하였습니다. 하지만, Java와 Spring을 이용한 환경에서는 통신이 되지 않았습니다. 이는 Java의 경우 인증서가 저장되어 있는 위치에서, JVM이 서버 측 인증서의 신뢰성을 검증하게 되기 때문인데요! 다시 말하면, $JAVA_HOME/lib/security/cacerts 위치에 루트 또는 중간 인증서가 등록되어 있지 않기 때문입니다.

 

📝해결

 

처음에는, 인증서를 무시하는 방법으로 통신을 시도하고자 하였습니다. RestTemplate를 이용해서 데이터를 요청하고 수신하는 코드를 작성한다고 할 때, RestTemplate를 만드는 설정 코드를 아래와 같이 작성할 수 있습니다.

/* 예시 */
@Bean
public RestTemplate restTemplate() throws Exception {
    TrustManager[] trustManager = new TrustManager[]{
        new X509TrustManager() {
            public void checkClientTrusted(X509Certificate[] certs, String authType) {}
            public void checkServerTrusted(X509Certificate[] certs, String authType) {}
            public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
        }
    };

    SSLContext sslContext = SSLContext.getInstance("TLS");
    sslContext.init(null, trustManager, new SecureRandom());

    HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();
    requestFactory.setHttpClient(HttpClients.custom().setSSLContext(sslContext)
        .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE).build());

    return new RestTemplate(requestFactory);
}

 

여기서 핵심은 인증서 검증을 하지 않기 위해, 인증서의 유효성을 검증하는 인터페이스인 TrustManger를 새롭게 정의해주는 거였습니다. 위의 코드는 모든 인증서를 신뢰하는 방법입니다.

 

그런데 위의 코드는 인증서 검증 단계를 뛰어넘기에 빠르게 테스트를 해 볼 수 있다는 장점을 가질 수는 있겠지만, 운영 환경에서 중간자공격에 매우 취약하다는 단점이 있습니다. 실제 서버라고 믿었던 API가 제3자가 임의로 설정한 API일 수 있습니다. 해당 API를 이용하게 되면 실제 서버와도 통신하면서 진짜 서버처럼 보일 수 있겠지만, 중간의 제3자가 여러 중요한 정보를 확인할 수 있습니다.

 

그래서 방법을 바꿔 특정 서버에게 받았던 인증서를 추출하여 통신해보기로 하였습니다. 

 

아래 주소의 방법을 이용하여 서버에게 받았던 인증서(.cer)를 내보냈고, 이 인증서를 제 노트북 특정 디렉토리에 저장하여 통신하기로 하였습니다.

 

클라이언트 인증을 위해 신뢰할 수 있는 클라이언트 CA 인증서 체인 내보내기 - Azure Application Gatew

Azure Application Gateway에서 클라이언트 인증을 위해 신뢰할 수 있는 클라이언트 CA 인증서 체인을 내보내는 방법을 알아봅니다.

learn.microsoft.com

 

/* 예시 */
@Bean
public RestTemplate restTemplate() throws Exception {

    String cerPath = "C:\\cacerts\\test.cer"; 
    FileInputStream fis = new FileInputStream(certPath);

    KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
    keyStore.load(null, null);
    keyStore.setCertificateEntry("custom-ca", CertificateFactory.getInstance("X.509").generateCertificate(fis));
    SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(keyStore, null).build();

    SSLConnectionSocketFactory socketFactory = new SSLConnectionSocketFactory(sslContext);
    CloseableHttpClient httpClient = HttpClients.custom().setSSLSocketFactory(socketFactory).build();
    HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);

    return new RestTemplate(requestFactory);
}

 

그런데 로컬이 아닌 test 환경에 해당 인증서를 옮기는 방법을 택하고 싶지 않았습니다. 따라서 프로젝트에 인증서를 포함시키려고했는데, .cer 파일이나 .pem 파일을 바로 포함시키면 text editor 등으로 확인이 쉽다는 문제점이 있었습니다. 따라서 .jks 파일을 이용하기로 했는데요!

 

윈도우 환경이었기에, powershell에서 아래의 명령어(keytool 이용)를 이용하여 .cer 파일을 .jks로 바꿨습니다.

keytool -importcert -alias test 
-file C:\cacerts\test.cer -keystore C:\cacerts\test.jks 
-storepass (비밀번호) -noprompt

 

이후 프로젝트의 resouces 경로에 해당 파일을 추가해주면서 로컬 통신을 하였고 성공하였습니다. (resources/cacerts/test.jks)

@Bean
public RestTemplate restTemplate() throws Exception {
    
    String password = "(비밀번호)";

	/* 1 또는 2 선택 */
	1) FileInputStream 이용한다면
    String keyPath = "src/main/resources/cacerts/test.jks";
    KeyStore keyStore = KeyStore.getInstance("JKS");
    try (InputStream is = new FileInputStream(keyPath)) {
        keyStore.load(is, password.toCharArray());
    }
    2) ClassPath 이용한다면
    try (InputStream is = new ClassPathResource("cacerts/test.jks").getInputStream()) {
        keyStore.load(is, password.toCharArray());
    }

    SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(keyStore, null).build();
    SSLConnectionSocketFactory socketFactory = new SSLConnectionSocketFactory(sslContext);
    CloseableHttpClient httpClient = HttpClients.custom().setSSLSocketFactory(socketFactory).build();
    return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient));
}

//비밀번호, 경로는 application.properties(or yml)이나 외부(예: 빌드하면서 설정에 추가해주기)를 이용하는 걸 추천합니다!

 

그런데 해당 코드를 git으로 올려 테스트 환경으로 빌드/배포한 뒤, 깨닫게 된 문제점이 있었습니다.

 

로컬 PC와 외부 API와 통신할 때 외부 서버로부터 받았던 인증키가, 테스트 서버와 외부 API와 통신할 때 외부 서버로부터 받았던 인증키가 달랐던 겁니다!! (예: 공개키) 그래서 똑같은 문제점이 발생하였습니다.

 

그래서 외부와 다시 통신하기 위해서, 1)인증키를 추출(.pem)한 뒤 2)서버에 저장(.jks)하고 3)저장된 인증키를 사용하여 통신하기로 결정합니다.

 

1) openssl 이용하여 외부 서버 인증서 추출(복사) [.pem]

openssl s_client -connect (도메인 or ip):(port) -showcerts </dev/null > /test/cacerts/test.pem

 

2) keytool을 이용하여 .jks 로 변환

keytool -importcert -alias test 
-file /test/cacerts/test.pem -keystore /test/cacerts/test.jks 
-storepass (비밀번호) -noprompt

 

3) java 코드에 FileInputStream 등을 이용하여 통신하게끔 설정

 

이럴 경우 서버에서도 통신이 가능하게 됩니다. (고민했던 부분) 물론 단점도 존재합니다. 인증서는 유효기간이 있습니다. 따라서 유효기간에 맞춰서 지속적으로 갱신을 해줘야 하는데요!! 😂

 

추가 참고 내용으로, jks 관련하여 아래의 인프런 Q&A를 참고해주시면 감사하겠습니다.

 

인증서, 공개키 파일은 언제 사용하나요? - 인프런 | 커뮤니티 질문&답변

누구나 함께하는 인프런 커뮤니티. 모르면 묻고, 해답을 찾아보세요.

www.inflearn.com

 

제일 좋은 방법은 1) 인증키를 JVM에 저장하고 2) 인증서가 만료될 때를 대비하여 자동 업데이트 환경을 구축하는 거라고 생각합니다. 자동으로 인증서를 갱신해주거나 알림을 통해 확인하면서, 인증서를 JVM에 등록한다면 설정을 따로 추가하지 않고도 편리한 통신이 가능할 것 같습니다. 물론 위와 같은 방법을 이용하시는 것도 여러 방법 중 하나의 해결 방법(-ing)이 될 수 있다고 생각합니다.

 

 

틀린 정보가 있다면, 말씀해주시면 감사하겠습니다😊

 

References

1. HTTPS (SSL) 통신과정(handshake) 그림으로 이해하기

2. [Network] TLS 통신 쉽게 알아보기

3. JAVA SSL 인증서 문제 - PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException

4. 공개키 기반구조(PKIX)

5. 클라이언트 인증에 사용할 신뢰할 수 있는 클라이언트 CA 인증서 체인 내보내기

6. 인증서, 공개키 파일은 언제 사용하나요? (인프런)