React Native와 WebSocket과 자체 서명된(Self-Signed) 인증서

2024. 8. 8. 19:01Programming/React Native

반응형

때는 바야흐로 2024년 08월 초, 임베디드 회사에서 WebRTC를 도입하기 위해 이런저런 시도를 하고 있을 때였다. 문득 Flutter를 사용해서 WebRTC로 수신한 영상 재생 및 데이터 채널을 통한 통신이 되는 것을 확인했을 무렵, '왜 이번 프로젝트 시작할 때 React Native를 안 썼더라...?'하는 의문이 들기 시작했다. 물론 React Native를 써본적도 없고, React는 조금 깔짝거려봤지만, 코드가 Typescript로 되어있으면 유지보수 할 수 있는 인원도 많아지니 좋지 않을까...하는 생각이 기저에 깔려있었기에 드는 의문이었다. 당장 진행중인 프로젝트를 몽땅 다 갈아엎지는 못하더라도, 지금까지 Flutter로 작성한 WebRTC 테스트용 샘플 앱을 React Native로 다시 만들어보기로 했다.

React-Native는 WebSocket을 지원합니다

...라고 공식 문서의 WebSocket Support에 나와있다. 다음의 예제 코드를 살펴보자. 매우 간략하게 나와있는데 비해서 이 이상 별다른 설명을 찾을수가 없다.

const ws = new WebSocket('ws://host.com/path');

ws.onopen = () => {
  // connection opened
  ws.send('something'); // send a message
};

ws.onmessage = e => {
  // a message was received
  console.log(e.data);
};

ws.onerror = e => {
  // an error occurred
  console.log(e.message);
};

ws.onclose = e => {
  // connection closed
  console.log(e.code, e.reason);
};

재밌는 것은 rejectUnauthorized같은 옵션에 대한 설명이 나와있지도 않고, 인자를 받는 옵션도 없다. 임베디드 장치 내에서 SSL 인증을 위해 사용하는 인증서는, 자체서명(Self-signed) 인증서인 경우가 대부분이다. 임베디드 장치를 구매한 사용자가 악의적인 목적으로 리버싱 엔지니어링 혹은 해킹을 통해서 SSL 인증서를 탈취했을 때, 동일한 임베디드를 구매한 다른 사용자까지 피해가 번지는 것을 막기 위해서 기기 고유값을 기반으로 인증서를 생성하는 것이 일반적이기 때문. 아무튼 이런 이유로 인해서 나는 자체 서명 인증서를 허용해야하는 상황이었는데, WebSocket에 대한 옵션을 어디에서도 찾을 수 없었기에 당황스러웠다.

React-Native WebSocket에서 제공하는 Options

급기야는 globals.d.ts를 열어보니 다음과 같은 내용을 확인할 수 있었다. 다행히도 WebSocket 생성자가 세 번째 인자로 options를 받는 것을 확인할 수 있었다.

interface WebSocket extends EventTarget {
  readonly readyState: number;
  send(data: string | ArrayBuffer | ArrayBufferView | Blob): void;
  close(code?: number, reason?: string): void;
  ping(): void;
  onopen: (() => void) | null;
  onmessage: ((event: WebSocketMessageEvent) => void) | null;
  onerror: ((event: WebSocketErrorEvent) => void) | null;
  onclose: ((event: WebSocketCloseEvent) => void) | null;
  addEventListener: WebsocketEventListener;
  removeEventListener: WebsocketEventListener;
}

declare var WebSocket: {
  prototype: WebSocket;
  new (
    uri: string,
    protocols?: string | string[] | null,
    options?: {
      headers: {[headerName: string]: string};
      [optionName: string]: any;
    } | null,
  ): WebSocket;
  readonly CLOSED: number;
  readonly CLOSING: number;
  readonly CONNECTING: number;
  readonly OPEN: number;
};

그래서 아래와 같이 rejectUnauthorized 옵션을 넣어보기로 했다.

const ws = new WebSocket(url, [], {
  headers: {},
  rejectUnauthorized,
});

ws.onopen = event => console.log('open!');
ws.onclose = event => console.log('close!');

실행해봤더니 rejectUnauthorized 옵션을 찾을 수 없다는 내용과 함께 onclose가 호출되는 것을 확인할 수 있었다. rejectUnauthorized는 브라우저에서만 사용가능하나보다... 하려는 찰나, 터미널에 java.security.cert.CertPathValidatorException: Trust anchor for certification path not found. 예외가 발생한 것을 발견했다. 엥? Java에서 예외가 발생했다구...?

혹시나싶어서 깃허브에서 React Native 소스코드 중 WebSocket 관련 내용을 찾아보니, WebSocketModule.java에서 구현체를 찾을 수 있었다. 아무래도 React Native에서 제공하는 options는 헤더에 옵션 값들을 추가하기 위한 인자인 듯 하다.

아무래도 자체서명된 인증서를 허용한다는 건 CA를 거치지 않는만큼 보안에 취약하기 때문에, 허용하지 않는 듯 했다. 안드로이드 자체적으로도 커스터마이징해서 허용할 수 있는데 어째서지. Flutter에서는 WebSocket 관련된 내용을 Dart에서 처리하다보니 큰 문제가 없었는데, React Native에서는 플랫폼 사이드에서 처리하기 때문에 문제가 생기는 듯 했다.

방법은 있지만, 문서에 적어두지 않았을 뿐이야

그렇게 React Native를 도입하려는 야심찬 나의 계획은 불발로 끝나는 듯 했으나, React Native의 WebSocketModule.java 파일을 좀 살펴보던 도중 의심스러운 녀석을 발견했다.

private static @Nullable CustomClientBuilder customClientBuilder = null;

public WebSocketModule(ReactApplicationContext context) {
  super(context);
  mCookieHandler = new ForwardingCookieHandler(context);
}

public static void setCustomClientBuilder(CustomClientBuilder ccb) {
  customClientBuilder = ccb;
}

private static void applyCustomBuilder(OkHttpClient.Builder builder) {
  if (customClientBuilder != null) {
    customClientBuilder.apply(builder);
  }
}

바로 static으로 선언해놓은 customClientBuilderCustomClientBuilder를 할당하는 setCustomClientBuilder()함수와, customClientBuilder에 값이 할당되어있을 시 OKHttpClient.BuildercustomClientBuilder를 적용하는 applyCustomBuilder()함수가 그것이다.

문서에는 나와있지 않지만, WebSocket에 사용하는 클라이언트를 커스터마이징하는 기능을 넣어둔 것이다. 어째서지... 아무래도 Let's Encrypt처럼 무료로 SSL 인증서를 발급해주는 서비스도 존재하는 마당에, 자체 서명된 인증서를 사용하는 것 자체가 비일반적인 일이 되어버렸기 때문일까. 아무튼 클라이언트를 커스터마이징 할 수 있는 함수가 정의되어있다면, 테스트해보면 되는 일이다.

자체 서명된 인증서를 허용하기 위한 온몸 비틀기

MainApplication.kt 파일을 열어서 onCreate() 하단에 아래와 같이, WebSocketModule.setCustomClientBuilder()를 호출해서 클라이언트를 커스터마이징해준다. 가장 중요한 부분은 hostnameVerifier를 설정해주는 부분이다. 인자로 전달받는 hostname은 호스트 주소이며 sessionSSLSession 객체로, SSL 인증서를 포함하여 SSL 세션과 관련되어있는 내용들이 적혀있다.

hostnameVerifier는 올바른 대상에 대해서는 true를 그렇지 않은 대상에 대해서는 false를 반환하게 되는데, 당연히 true를 반환할 경우에는 예외가 발생하지 않고 false를 반환할 경우에는 예외가 발생하게 된다. 일단 테스트를 위해서 무조건 true를 반환하게 해뒀지만, 최소한의 안정장치를 위해 session객체 내부에 저장되어있는 값을 통해 대상을 확인한 후 true를 반환할 수 있도록 하자.

WebSocketModule.setCustomClientBuilder { customClientBuilder ->
  val trustAllCerts = object: X509TrustManager {
    override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}

    override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}

    override fun getAcceptedIssuers(): Array<X509Certificate> {
      return arrayOf()
    }
  }
  val sslContext = SSLContext.getInstance("SSL")
  sslContext.init(null, arrayOf(trustAllCerts), SecureRandom())

  customClientBuilder.sslSocketFactory(
    sslSocketFactory =  sslContext.socketFactory,
    trustManager = trustAllCerts
  )

  customClientBuilder.hostnameVerifier { hostname, session -> true }
}

만약 Expo를 사용하고 있다면 android/ios 경로가 존재하지 않는데, 네이티브 파일을 Expo에서 관리하기 때문이다. 따라서 eject를 거쳐 Expo kit으로 전환하는 과정을 거쳐야한다.

아무튼 됐죠?

WebRTC를 사용하기 위해서 넘은 산은 몇 개 더 있지만, WebSocket을 사용하는 것 외에 UI를 작성하는 것은 꽤나 유쾌한 경험이었다. 비록 CSS를 능숙하게 다루는 것은 아니지만 생각보다 쉽게 레이아웃을 잡을 수 있었고, 잠깐이나마 hooks를 사용해서 상태를 변경하는 것도 즐거웠다.

다만 WebSocket처럼 문서가 미흡한건지 아니면 내가 못찾은건지 모를 이슈가 발생했을시에는 뒷감당이 어려울 것 같아서, 이번 프로젝트는 React Native 대신 Flutter를 사용해서 진행하기로 했다.

반응형