MTLS 인증서 테스트

신현기·2023년 6월 9일
0

MTLS

목록 보기
2/2

MTLS 테스트 준비

서버, 클라이언트의 keystore/truststore를 준비해야 합니다.

이때 서로의 keystore에 대한 루트 인증서를 각자의 truststore에 넣어둬야합니다.

서버의 인증서와 클라이언트의 인증서는 Handshake 과정에서 데이터를 암호화할 알고리즘을 정하므로 양쪽의 암호화 알고리즘을 맞춰두어야 합니다.
ex) RSA 혹은 ECDSA

제가 사용한 알고리즘은 RSA 방식입니다.

테스트용 간단한 클라이언트 만들기

Handshake 후 application에 도달하는 과정까지만 테스트가 필요하기 때문에
간단한 로직과 java swing을 사용했습니다.

클라이언트 라이브러리로 사용할 okhttp3를 추가합니다.

implementation 'com.squareup.okhttp3:okhttp:4.9.3'

우선 화면 구성은 아래와 같습니다.

연결할 서버의 주소를 입력한 후
등록한 keystore, truststore 파일을 가지고 handshake를 시도합니다.

시도 과정의 로그는 아래 textarea에 표시합니다.

이제 실제 기능을 추가합니다.

JKS 파일용 리스너

public class FileOpenListener implements ActionListener {
  private final JFileChooser chooser;
  private final JLabel fileLabel;
  private final JTextArea logArea;

  public FileOpenListener(JFileChooser chooser, JLabel fileLabel, JTextArea logArea) {
    this.chooser = chooser;
    this.fileLabel = fileLabel;
    this.logArea = logArea;
  }

  @Override
  public void actionPerformed(ActionEvent e) {
    // jks 타입의 파일만 필터링한다.
    FileNameExtensionFilter filter = new FileNameExtensionFilter("", "jks");
    chooser.setFileFilter(filter);

    int ret = chooser.showOpenDialog(null);
    if (ret != JFileChooser.APPROVE_OPTION) {
      return;
    }


    File file = chooser.getSelectedFile();
    if (!file.exists()) {
      addLog(logArea, "파일이 존재하지 않습니다.");
      return;
    }

    // 가져온 파일명을 화면에 뿌려준다.
    String text = fileLabel.getText();
    if (text.contains("-")) {
      fileLabel.setText(text.replace(text.substring(text.indexOf("- ")), "- " + file.getName()));
    } else {
      fileLabel.setText(text + " - " + file.getName());
    }
    addLog(logArea, "파일 등록 완료");
  }
}

만든 리스너를 패널에 등록해줍니다.

// 리스너를 실행할 버튼
JMenuItem keystoreOpen = new JMenuItem("open");
// 리스너
FileOpenListener keyFileOpenListener = new FileOpenListener(keystoreChooser, keystoreLabel, log);
keystoreOpen.addActionListener(keyFileOpenListener);

keystorePanel.add(keystoreOpen);

// 비밀번호 작성용 라벨, text field
JLabel keyPwdLabel = new JLabel("keystore password : ");
keystorePanel.add(keyPwdLabel);

keystorePanel.add(keyPwdText);

커넥션용 리스너

서버가 웹소켓 서버이기 때문에 웹소켓 커넥션을 열 클라이언트를 만듭니다.

우선 받은 JKS 파일들을 실제 사용할 객체로 변환시켜야 합니다..

아래 함수는 파일이 정상적이고 비밀번호도 일치한다면 KeyManagerFactory를 반환합니다.
그 외엔 null을 반환합니다.

private KeyManagerFactory readKeystore() throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException, UnrecoverableKeyException {
    // 비밀번호 검증
    if (keyPwd.getText().length() < 6 || keyPwd.getText() == null || keyPwd.getText().equals("")) {
      addLog(jTextArea, "keystore 비밀번호 확인 필요");
      return null;
    }
    // 파일 존재여부 확인
    if (keystoreChooser.getSelectedFile() == null) {
      addLog(jTextArea, "keystore 파일 등록 필요");
      return null;
    }

    addLog(jTextArea, "keystore 파일 조회..");
    
    char[] password = keyPwd.getText().toCharArray();
    File keystoreFile = keystoreChooser.getSelectedFile();
    log.debug("keystoreFile exist : {}", keystoreFile.exists());

    if (keystoreFile.exists()) {
      addLog(jTextArea, "keystore 등록 완료");
    } else {
      // 파일 등록 실패시 Exception 발생
      addLog(jTextArea, "keystore 등록 실패");
      throw new RuntimeException("keystore 파일 등록 실패");
    }
    KeyManagerFactory keyManagerFactory;
    try {
      KeyStore keyStore = KeyStore.getInstance(keystoreFile, password);
      addLog(jTextArea, "클라이언트 인증서 시그니처 알고리즘 : " + keyStore.getCertificate(keyStore.aliases().nextElement()).getPublicKey().getAlgorithm());
      keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
      keyManagerFactory.init(keyStore, password);
    } catch (IOException e) {
      if (e.getMessage().contains("password was incorrect")) {
        // 입력받은 비밀번호가 일치하지 않는 경우
        addLog(jTextArea, "keystore 비밀번호 불일치");
      } else {
        addLog(jTextArea, "keystore 등록 실패");
        addLog(jTextArea, e.getMessage());
      }
      return null;
    }

    return keyManagerFactory;
  }

같은 방식으로 truststore로 객체로 가져온 후 모두 정상이라면 SslContext를 만들어줍니다.

// Handshake에 사용할 커넥션 스펙
ConnectionSpec spec = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
        .tlsVersions(TlsVersion.TLS_1_3)
        .cipherSuites(
          CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
          CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
          CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
          CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
        )
        .build();


SSLContext sslContext = SSLContext.getInstance("SSL");

// keystore
KeyManagerFactory keyManagerFactory = readKeystore();
if (keyManagerFactory == null) {
    return;
}

// truststore
TrustManagerFactory trustManagerFactory = readTruststore();
if (trustManagerFactory == null) {
    return;
}

// SslContext에 key manager, trust manager 등록
sslContext.init(keyManagerFactory.getKeyManagers(),trustManagerFactory.getTrustManagers(), new SecureRandom());

// 클라이언트 객체 생성
OkHttpClient client = new OkHttpClient.Builder()
    .connectionSpecs(Collections.singletonList(spec))
    .sslSocketFactory(sslContext.getSocketFactory(), new TrustManager())
    .protocols(List.of(Protocol.HTTP_1_1))
    .build();

이제 위에서 생성한 클라이언트 객체로 커넥션을 시도합니다.

// 요청 객체 생성
Request request = new Request.Builder()
        .url(url.getText())
        .addHeader("Upgrade", "websocket")
        .addHeader("Connection", "upgrade")
        .get()
        .build();
        
// okhttp3.WebSocketListener를 extends한 웹소켓 리스너
WebSocketListener listener = new WebSocketListener(jTextArea);

WebSocket ws = client.newWebSocket(request, listener);

addLog(jTextArea, "요청 시도..");

addLog(jTextArea, "요청 URL : " + ws.request().url());
addLog(jTextArea, "요청 프로토콜 : " + client.protocols());
addLog(jTextArea, "요청 헤더 : \n" + ws.request().headers());

try {
    client.newCall(ws.request()).execute();
} catch (Exception err) {
    log.error(err.getMessage());
}

okhttp3.WebSocketListener 객체를 extends한 웹소켓 리스너는 handshake가 완료되고 커넥션이 열린경우 응답 헤더와 사용한 cipher suite만 확인합니다.
여기까지 들어온다면 테스트는 성공한 것 입니다.

@Override
public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) {
    log.debug("websocket connected");
    addLog(logField, "응답 헤더\n==============\n" + response.headers());
    addLog(logField, "요청 완료");
    if (response.handshake() != null) {
      addLog(logField, "사용 CIPHER SUITE : " + response.handshake().cipherSuite().javaName());
    }
    webSocket.close(NORMAL_CLOSURE_STATUS, null);
}


// handshake가 실패한 경우
@Override
public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, @Nullable Response response) {
    t.printStackTrace();
    addLog(logField, "요청 실패");
    addLog(logField, t.getMessage());
}

이제 커넥션 관련 리스너도 등록해준뒤 테스트를 시작합니다.

JButton button = new JButton("연결");
ConnectionListner connectionListner = new ConnectionListner(url, chrgrIdntfr, log, keystoreChooser, truststoreChooser, keyPwdText, trustPwdText);
button.addActionListener(connectionListner);

테스트 시작!

클라이언트의 keystore, truststore 모두 등록한 뒤 연결을 시도합니다

정상적으로 연결이 되었습니다!

RSA_SHA256이 사용된다고 합니다.

이상으로 테스트를 마무리하겠습니다..

profile
구르는거 좋아하는 개발자

0개의 댓글