dev/Java&Spring

Spring에서 Client Authentication (two-way TLS/SSL) 구현하기

lugi 2019. 4. 7. 17:55

https://springboot.cloud/19 에서는 내부망 혹은 사설인증서를 통해 TLS/SSL을 구현할 경우 CA를 신뢰할 수 없어 검증을 회피하는 로직을 적어보았다. 이 경우는 HTTPS를 통해 전달 과정에서 암호화는 유지가 되지만, 이것이 신뢰할 수 있는지 없는지는 신경을 쓰지 않는 방법이다.

반대로, 클라이언트와 서버간에 매칭이 되는 인증서를 소유하고 있지 않다면 서로 접근을 아예 거부하는 방법이 필요할 수도 있다. 뭔가 정보 수집을 하는 서버-에이전트간에 인증된 에이전트에서만 정보를 받아들이게 한다던가 뭐 그런 경우로 쓰고 있는데, 간단하게 구현을 할 때는 HTTP HEADER에 AUTH에 관한 부분을 삽입해서 그걸 체크하거나, 좀 더 복잡한 구현이 필요하다면 해당 헤더에 들어가는 값을 주기적으로 바꿔서 DB등에서 가져오게 한 후 삽입을 하거나 하는 식으로 구현을 하였다. 이 포스팅에서는 TLS/SSL에서 인증서를 검증한 후 실패한다면 아예 접근을 거부하도록 하는 방법을 적어보려 한다.

이와 같은 구현을 하게 되면 웹브라우저에서 신뢰할 수 없는 인증서이지만 이동하겠냐는 확인을 하거나, insecure SSL 요청을 하여도 응답을 받을 수 없고, TLS/SSL은 전송 계층 레이어의 암호화이므로, 이 방법은 HTTPS뿐 아니라 TCP 소켓등을 통한 연결에도 적용될 수 있다는 장점이 있다. (kafka와 같은 메시지 브로커에도 내부에 client-auth property를 설정하고 키를 설정하게 되어 있는데 이것과 동일하다)

SSL에 대해 기본적인 사항을 짚고 넘어가면 대충 아래와 같다

  1. PKI
    • Public Key Infrastructure
    • 공개키 암호화 방식을 기반으로 하는 암호화/인증 방식의 총칭
    • 공개키 기반의 인증서 관련 프로세스 및, 관련된 Authority 등을 포함
  2. Public Key
    • 모두에게 공개되는 키
  3. Private Key
    • 특정한 사람만이 알고 있는 키
  4. CA (Certificate Authority)
    • 인증 기관
    • 공개키 기반 암호화를 위한 인증서를 발급, 관리하는 서비스를 제공하는 신뢰할 수 있는 제3자
    • 인증서에 대한 정보를 인증하는 역할 등을 수행함
    • 인증 기관들도 상-하위 관계가 있음. 최상위 기관을 ROOT CA라고 지칭함
  5. 인증서
    • 인증서에는 암호화를 위한 인증서 소유자의 공개키 정보와 인증서 소유자임을 보증할 수 있는 개인키 서명등이 포함됨
    • 일반적으로 우리가 발급 받는 인증서는 신뢰할 수 있는 기관이 발급하고, 상위 기관의 서명을 받음
  6. Self-signed 인증서
    • 최상위 인증기관의 인증서는 누군가 더 상위에서 서명을 해 줄 수가 없으므로 스스로 서명을 하여 인증서를 발급함
    • OS나 브라우저등은 신뢰할 수 있는 ROOT CA의 인증서들을 미리 탑재하고 있음
    • 개인이 직접 발급하고 스스로 서명하여 ROOT CA와 같이 행동할 수도 있음
    • 이 때 개인이 직접 발급하고 스스로 서명한 인증서는 ROOT CA 정보가 어디에도 등록되어 있지 않으므로, 신뢰할 수 없는 인증서로 경고가 발생함
    • 이러한 문제를 해결하기 위해서 스스로 ROOT CA를 OS나 브라우저등에 등록하거나, 인증서 검증로직을 회피하도록 프로그래밍함 (https://springboot.cloud/19 와 같이)
  7. CSR (Certificate Signing Request)
    • 인증서 서명 요청
    • 공개키+인증서에 포함되어야 하는 식별정보(신청자, 적용 대상 도메인 등)를 담은 인증서 발급 요청 정보
  8. SSL 인증서 발급 과정
    • ROOT CA 인증서 생성
      • ROOT CA의 Key로 KeyPair 생성
      • ROOT CA인증서 발급을 위한 CSR 생성
      • CSR에 자신의 KeyPair로 서명하여 ROOT CA 인증서 발급
    • SSL 인증서 발급
      • SSL에서 사용할 KeyPair 생성
      • SSL인증서 발급을 위한 CSR 생성
      • ROOT CA Key로 서명하여 SSL 인증서 발급
  9. Java의 관점
    • keystore : 인증서 및 그와 관련된 개인키를 보관하는 공간. 공개키로 암호화 된 정보가 왔을 때 이 정보를 이용해 통신할 수 있다.
    • truststore : 신뢰할 수 있는 인증서의 목록을 보관하는 공간 (일반적으로 cacerts 파일이 기본 truststore이다, custom truststore에는 custom keystore를 import하여 생성한다)

물론 인증서에 대해서 모두 짚고 넘어가려면 PKI에서 사용하는 RSA나 ECDSA, 그 정보를 암호화하기 위한 AES나 인증서의 정보를 담는 표준 문법인 ASN이나 인증서 저장포맷인 PKCS들에도 다루고 넘어가야하겠지만, 내가 그것을 전체적으로 설명할 실력은 되지 않는 것 같으므로... 궁금하시면 위의 키위드를 바탕으로 더 검색을 해 보시면 되겠다 :)

Client-authentication, 혹은 two-way SSL이라고 불리는 것에 대해서 잠깐 살펴 보자면, https://www.ibm.com/support/knowledgecenter/en/SSRMWJ_7.0.1/com.ibm.isim.doc/securing/cpt/cpt_ic_security_ssl_scenario.htm 에 나와 있는 도식을 참고하자면.

  1. 클라이언트의 인증서와 서버의 인증서를 각각 생성하여
  2. 클라이언트의 신뢰할 수 있는 CA에 서버의 인증서를 추가하고
  3. 서버의 신뢰할 수 있는 CA에 클라이언트의 인증서를 추가하여
    양쪽이 양호 신뢰할 수 없는 경우에는 연결을 거부하는 방법이다.

SpringBoot는 Java기반이므로 java keytool로 생성하는 jks 파일 및 truststore를 사용한다. 이 때 사용하는 keytool 커맨드는 다음과 같다.

:: 서버용 키스토어 생성
keytool -genkey -alias gnu-server-key -keyalg RSA -keypass server -storepass server -keystore server.jks -validity 365 -keysize 2048 -dname "CN=localhost,OU=gnu,O=gnu,L=gnu,S=gnu,C=KR"
:: 클라이언트용 키스토어 생성
keytool -genkey -alias gnu-client-key -keyalg RSA -keypass client -storepass client -keystore client.jks -validity 365 -keysize 2048 -dname "CN=localhost,OU=gnu,O=gnu,L=gnu,S=gnu,C=KR"
:: Truststore에 인증서를 등록하기 위해서는 X.509로 export가 필요함 (클라이언트)
keytool -export -alias gnu-client-key -keystore client.jks -file client_X509.cer -storepass client
:: Truststore에 인증서를 등록하기 위해서는 X.509로 export가 필요함 (서버)
keytool -export -alias gnu-server-key -keystore server.jks -file server_X509.cer -storepass server

:: 클라이언트 인증서를 서버의 truststore에 등록함
keytool -import -alias gnu-client-key -keystore server.truststore -file client_X509.cer -storepass server
:: 서버의 인증서를 클라이언트의 truststore에 등록함
keytool -import -alias gnu-server-key -keystore client.truststore -file server_X509.cer -storepass client

이렇게 해서 생성한 인증서 파일을 Spring Boot 의 application.properties에서는
server.ssl.enabled=true
server.ssl.key-store=c:/key/server.jks
server.ssl.key-store-password=server
server.ssl.key-store-type=jks
server.ssl.key-alias=gnu-server-key
server.ssl.trust-store=c:/key/server.truststore
server.ssl.trust-store-password=server
server.ssl.trust-store-type=jks
server.ssl.client-auth=need

과 같이 서버측 key와 truststore를 등록해주고 server.ssl.client-auth=need로 설정해주면 서버측과 클라이언트측이 인증서가 상호합의 되지 않을 경우 연결을 거부하게 된다.

클라이언트측에서 서버로 요청을 하기 위한 설정 부분은 다음과 같다

private static final String CLIENT_TRUST_STORE = "C:/key/client.truststore";
private static final String CLIENT_KEY_STORE = "C:/key/client.jks";
@LocalServerPort
private int rdmServerPort;
private String uri = "";

private static final String KEY_STORE_PASS = "client";
private static final String TRUST_STORE_PASS = "client";

/**
 * two-way 인증을 위한 Client측 인증 정보를 설정한다.
 */
@BeforeClass
public static void initBeforeClass() {
    System.setProperty("javax.net.debug", "all");
    System.setProperty("javax.net.ssl.keyStore", CLIENT_KEY_STORE);
    System.setProperty("javax.net.ssl.keyStorePassword", KEY_STORE_PASS);
    System.setProperty("javax.net.ssl.trustStore", CLIENT_TRUST_STORE);
    System.setProperty("javax.net.ssl.trustStorePassword", TRUST_STORE_PASS);
}

keyStore 및 trustStore와 password는 System environment로 설정하도록 한다. 이렇게 할 경우 RestTemplate에서는

/**/
 * two-way 인증을 통해 성공하는 RestTemplate이다.
 * 인증서의 CN이 localhost로 발급되어 있어 이 테스트는 hostname verify가 없어도 성공한다.
 * 만약 CN이 적절하지 않다면, one-way 때와 같이 hostname verifier는 구현해주어야 한다. 
 */
@Test
public void twoWayInsecureRestTemplateSuccess() {
    TestRestTemplate template = new TestRestTemplate();
    assertThat(template.getForEntity(uri, String.class).getStatusCode()).isEqualTo(HttpStatus.OK);
}

과 같이 별도의 설정이 없더라도 SSL 요청이 성공한다.

WebClient의 경우에는 System environment를 생성하더라도 WebClient 구동시 SSLException이 발생한다. RestTemplate과는 달리

public void twoWayInsecureWebClientSuccess() throws KeyStoreException, NoSuchAlgorithmException, CertificateException, FileNotFoundException, IOException, UnrecoverableKeyException, NoSuchFieldException, SecurityException, ClassNotFoundException, IllegalArgumentException, IllegalAccessException, NoSuchMethodException {
    KeyStore keystore = KeyStore.getInstance("jks");
    keystore.load(new FileInputStream(new File(CLIENT_KEY_STORE)), KEY_STORE_PASS.toCharArray());
    KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
    kmf.init(keystore, KEY_STORE_PASS.toCharArray());
    KeyStore truststore = KeyStore.getInstance(KeyStore.getDefaultType());
    truststore.load(new FileInputStream(new File(CLIENT_TRUST_STORE)), TRUST_STORE_PASS.toCharArray());
    TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
    tmf.init(truststore);
    SslContext ssl = SslContextBuilder.forClient().clientAuth(ClientAuth.REQUIRE).keyManager(kmf).trustManager(tmf).build();
    HttpClient httpClient = HttpClient.create().secure(builder -> builder.sslContext(ssl));
    ClientHttpConnector connector = new ReactorClientHttpConnector(httpClient);
    WebTestClient.bindToServer(connector).build().get().uri(uri).exchange().expectBody(String.class).consumeWith(response -> {
        assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
    });
}

와 같이 KeyStore와 TrustManager를 직접 설정해주어야 동작하는 것을 확인하였다.

이 때 주의할 점은 위에서 Key의 생성시 CN 정보를 localhost로 주었기 때문에 localhost 요청시 호스트 네임이 일치하여 성공을 한다. 그렇지만 CN의 정보와 호스트네임이 일치하지 않은 경우에는 RestTemplate은 ((HttpsURLConnection) connection).setHostnameVerifier((hostname, session) -> true);을 구현해 모든 Hostname을 pass하거나 key생성시 위와 다르게

:: generage server Keystore
keytool -genkey -alias gnu-server-key -keyalg RSA -keypass server -storepass server -keystore server.jks -validity 365 -keysize 2048 -dname "CN=gnu,OU=gnu,O=gnu,L=gnu,S=gnu,C=KR" -ext SAN=IP:127.0.0.1
:: generate client Keystore
keytool -genkey -alias gnu-client-key -keyalg RSA -keypass client -storepass client -keystore client.jks -validity 365 -keysize 2048 -dname "CN=gnu,OU=gnu,O=gnu,L=gnu,S=gnu,C=KR" -ext SAN=IP:127.0.0.1

과 같이 -ext SAN=IP:127.0.0.1 과 같은 서버IP를 SUB ALTNAME 필드에 IP를 추가해주어야 한다.

HttpsUrlConnection을 사용하는 RestTemplate의 경우에는 ((HttpsURLConnection) connection).setHostnameVerifier((hostname, session) -> true);으로 모든 Host에 대해 verify 처리가 가능함을 확인하였지만 Reactor Netty를 사용할 경우에는 이를 설정할 수 있는 부분이 없다. 다만 Exception stack trace를 보았을 때 https://github.com/unofficial-openjdk/openjdk/blob/9468cb28c956e35b1aa42d76822940840a906438/src/java.base/share/classes/sun/security/util/HostnameChecker.java 에서 확인되는 HostnameChecker는 동일하게 타는 것이 확인 되므로 -ext SAN=IP:127.0.0.1을 통해 RestTemplate과 WebClient 모두 특정 호스트를 신뢰하는 동작을 구현할 수 있다.

이와 관련된 구현도 https://github.com/gnu-gnu/spring-insecure-ssl-connect-test 의 테스트 패키지에서 SpringBootSslServerTestApplicationSslAuthTest.java를 통해 확인 가능하다.

참고한 곳 :
https://www.sslshopper.com/article-most-common-java-keytool-keystore-commands.html
https://www.ibm.com/support/knowledgecenter/en/SSRMWJ_7.0.1/com.ibm.isim.doc/securing/cpt/cpt_ic_security_ssl_scenario.htm
https://docs.oracle.com/cd/E19900-01/820-0849/ablqw/index.html
https://stackoverflow.com/questions/1666052/java-https-client-certificate-authentication
https://github.com/unofficial-openjdk/openjdk
https://stackoverflow.com/questions/13315623/netty-ssl-hostname-verification-support