보안
앱을 개발할 때 보안은 종종 간과되는 부분이다. 완벽히 뚫리지 않는 소프트웨어를 만드는 것은 사실상 불가능하다. 아직까지 완벽히 뚫리지 않는 자물쇠는 발명되지 않았기 때문이다. 결국 은행 금고도 뚫리는 경우가 있다. 하지만 악의적인 공격의 피해자가 되거나 보안 취약점에 노출될 가능성은, 그런 상황을 방지하기 위해 투자하는 노력에 반비례한다. 일반적인 자물쇠도 뚫릴 수 있지만, 그렇다고 해도 캐비닛 후크보다는 훨씬 뚫기 어렵다!
이 가이드에서는 민감한 정보를 저장하는 방법, 인증, 네트워크 보안, 그리고 앱을 보호하는 데 도움이 되는 도구들에 대한 모범 사례를 배울 수 있다. 이는 단순한 사전 점검 목록이 아니다. 앱과 사용자를 더욱 안전하게 보호할 수 있는 다양한 옵션을 제공하는 카탈로그에 가깝다.
민감한 정보 저장하기
앱 코드에 민감한 API 키를 절대 저장하지 않는다. 코드에 포함된 모든 정보는 앱 번들을 검사하는 누구나 평문으로 접근할 수 있다. react-native-dotenv나 react-native-config와 같은 도구는 API 엔드포인트와 같은 환경별 변수를 추가하는 데 유용하지만, 서버 측 환경 변수와 혼동해서는 안 된다. 서버 측 환경 변수는 종종 비밀 키나 API 키를 포함할 수 있다.
앱에서 특정 리소스에 접근하기 위해 API 키나 비밀 키가 반드시 필요하다면, 가장 안전한 방법은 앱과 리소스 사이에 오케스트레이션 레이어를 구축하는 것이다. 이는 AWS Lambda나 Google Cloud Functions와 같은 서버리스 함수를 사용해 요청을 전달하면서 필요한 API 키나 비밀 키를 포함하는 방식이다. 서버 측 코드에 있는 비밀 키는 앱 코드에 있는 비밀 키와 달리 API 소비자가 동일한 방식으로 접근할 수 없다.
지속적인 사용자 데이터의 경우, 민감도에 따라 적절한 저장소 타입을 선택한다. 앱을 사용하다 보면 오프라인 사용을 지원하거나 네트워크 요청을 줄이기 위해, 혹은 사용자가 매번 재인증하지 않아도 되도록 액세스 토큰을 저장하는 등 기기에 데이터를 저장해야 할 필요가 생긴다.
지속적 vs 비지속적 데이터 — 지속적 데이터는 기기의 디스크에 기록되어 앱이 다시 실행될 때 네트워크 요청을 하거나 사용자가 다시 입력하지 않아도 데이터를 읽을 수 있다. 하지만 이는 공격자가 데이터에 접근할 가능성을 높인다. 비지속적 데이터는 디스크에 기록되지 않으므로 접근할 데이터 자체가 존재하지 않는다!
Async Storage
Async Storage는 React Native를 위한 커뮤니티에서 관리되는 모듈로, 비동기적이고 암호화되지 않은 키-값 저장소를 제공한다. Async Storage는 앱 간에 공유되지 않는다. 각 앱은 자체 샌드박스 환경을 가지며, 다른 앱의 데이터에 접근할 수 없다.
사용해야 할 경우 | 사용하지 말아야 할 경우 |
---|---|
앱 실행 간 비민감 데이터 지속 | 토큰 저장 |
Redux 상태 지속 | 비밀 정보 |
GraphQL 상태 지속 | |
앱 전체에서 사용되는 전역 변수 저장 |
개발자 노트
- Web
Async Storage는 웹의 Local Storage에 대응하는 React Native 기능이다.
안전한 데이터 저장
React Native는 기본적으로 민감한 데이터를 저장할 수 있는 기능을 제공하지 않는다. 하지만 Android와 iOS 플랫폼에서는 이미 사용 가능한 솔루션들이 존재한다.
iOS - 키체인 서비스
키체인 서비스는 사용자의 민감한 정보를 안전하게 저장할 수 있게 해준다. 이는 인증서, 토큰, 비밀번호, 그리고 Async Storage에 저장하기에 적합하지 않은 기타 민감한 정보를 보관하기에 이상적인 장소이다.
Android - 보안 Shared Preferences
Shared Preferences는 Android에서 지속적인 키-값 데이터 저장소로 사용된다. Shared Preferences의 데이터는 기본적으로 암호화되지 않지만, Encrypted Shared Preferences는 Shared Preferences 클래스를 감싸고, 키와 값을 자동으로 암호화한다.
Android - 키스토어
Android 키스토어 시스템은 암호화 키를 컨테이너에 저장해 기기에서 추출하기 어렵게 만든다.
iOS 키체인 서비스나 Android 보안 공유 환경설정을 사용하려면 직접 브릿지를 작성하거나, 라이브러리를 사용해 통합 API를 제공할 수 있다. 단, 이는 사용자의 책임 하에 이루어져야 한다. 고려해볼 만한 라이브러리는 다음과 같다:
민감한 정보를 의도치 않게 저장하거나 노출하지 않도록 주의한다. 예를 들어, 폼 데이터를 Redux 상태에 저장하고 전체 상태 트리를 Async Storage에 지속시키는 경우, 또는 사용자 토큰과 개인 정보를 Sentry나 Crashlytics 같은 애플리케이션 모니터링 서비스로 전송하는 경우가 있다.
인증과 딥 링크
모바일 앱은 웹에서는 존재하지 않는 특별한 취약점을 가지고 있다. 바로 딥 링크다. 딥 링크는 외부 소스에서 네이티브 앱으로 데이터를 직접 전달하는 방법이다. 딥 링크는 app://
형태로 표현되며, 여기서 app
은 앱의 스키마를 의미하고, //
뒤에 오는 내용은 앱 내부에서 요청을 처리하는 데 사용된다.
예를 들어, 전자상거래 앱을 개발한다면 app://products/1
과 같은 딥 링크를 사용해 앱을 열고 ID가 1인 제품의 상세 페이지를 표시할 수 있다. 이는 웹의 URL과 유사하지만 중요한 차이점이 있다.
딥 링크는 안전하지 않으며, 절대 민감한 정보를 포함해서는 안 된다.
딥 링크가 안전하지 않은 이유는 URL 스키마를 등록하는 중앙화된 방법이 없기 때문이다. 앱 개발자는 iOS에서는 Xcode에서 설정하거나, Android에서는 인텐트를 추가해 거의 모든 URL 스키마를 사용할 수 있다.
악의적인 앱이 동일한 스키마를 등록해 딥 링크를 가로채고 링크에 포함된 데이터에 접근하는 것을 막을 수 있는 방법은 없다. app://products/1
과 같은 링크를 보내는 것은 해롭지 않지만, 토큰을 보내는 것은 보안 문제를 일으킬 수 있다.
링크를 열 때 운영체제가 두 개 이상의 앱 중 선택해야 하는 경우, Android는 명확성 대화상자를 표시해 사용자에게 링크를 열 앱을 선택하도록 요청한다. 그러나 iOS에서는 운영체제가 대신 선택을 하기 때문에 사용자는 이를 전혀 알지 못한다. Apple은 이후 iOS 버전(iOS 11)에서 선착순 원칙을 도입해 이 문제를 해결하려고 했지만, 이 취약점은 여전히 다양한 방식으로 악용될 수 있다. 이에 대해 더 자세히 알고 싶다면 여기를 참고하자. iOS에서는 유니버설 링크를 사용해 앱 내 콘텐츠에 안전하게 연결할 수 있다.
OAuth2와 리다이렉트
OAuth2 인증 프로토콜은 현재 가장 완벽하고 안전한 프로토콜로 평가받으며 널리 사용된다. OpenID Connect 프로토콜도 이 기반으로 만들어졌다. OAuth2에서는 사용자가 제3자를 통해 인증을 받는다. 인증이 성공적으로 완료되면, 제3자는 요청한 애플리케이션으로 리다이렉트하며 검증 코드를 전달한다. 이 코드는 JWT(JSON Web Token)로 교환할 수 있다. JWT는 웹에서 당사자 간에 정보를 안전하게 전송하기 위한 개방형 표준이다.
웹 환경에서 이 리다이렉트 단계는 안전하다. 웹의 URL은 고유성이 보장되기 때문이다. 하지만 앱 환경에서는 이 보장이 적용되지 않는다. 앞서 언급했듯이, URL 스킴을 등록할 수 있는 중앙화된 방법이 없기 때문이다. 이 보안 문제를 해결하기 위해 PKCE라는 추가 검증 단계가 필요하다.
PKCE는 "Pixy"로 발음되며, Proof of Key Code Exchange의 약자다. OAuth 2 사양의 확장으로, 인증과 토큰 교환 요청이 동일한 클라이언트에서 왔는지 확인하는 추가 보안 계층을 제공한다. PKCE는 SHA 256 암호화 해시 알고리즘을 사용한다. SHA 256은 텍스트나 파일에 대해 고유한 "서명"을 생성하며, 다음과 같은 특징이 있다:
- 입력 파일의 크기와 상관없이 항상 동일한 길이의 결과를 생성한다.
- 동일한 입력에 대해 항상 동일한 결과를 보장한다.
- 단방향 알고리즘이라서 원본 입력을 역추적할 수 없다.
여기서 두 가지 값이 사용된다:
- code_verifier - 클라이언트가 생성한 큰 랜덤 문자열
- code_challenge - code_verifier의 SHA 256 해시 값
초기 /authorize
요청 시, 클라이언트는 메모리에 저장된 code_verifier
에 대한 code_challenge
도 함께 전송한다. 인증 요청이 성공적으로 반환된 후, 클라이언트는 code_challenge
를 생성하는 데 사용된 code_verifier
를 다시 전송한다. IDP는 code_challenge
를 계산하고, 초기 /authorize
요청에서 설정된 값과 일치하는지 확인한다. 값이 일치할 경우에만 액세스 토큰을 발급한다.
이렇게 하면 초기 인증 흐름을 시작한 애플리케이션만 검증 코드를 JWT로 교환할 수 있다. 악의적인 애플리케이션이 검증 코드를 얻더라도, 그 자체로는 무용지물이 된다. 실제 동작을 확인하려면 이 예제를 참고한다.
네이티브 OAuth를 구현할 때 고려할 만한 라이브러리는 react-native-app-auth다. React-native-app-auth는 OAuth2 프로바이더와 통신하기 위한 SDK로, 네이티브 AppAuth-iOS와 AppAuth-Android 라이브러리를 래핑하며 PKCE를 지원한다.
React-native-app-auth는 IDP가 PKCE를 지원할 경우에만 PKCE를 사용할 수 있다.
네트워크 보안
API는 항상 SSL 암호화를 사용해야 한다. SSL 암호화는 서버에서 데이터가 나와 클라이언트에 도달하기 전까지 평문으로 읽히는 것을 방지한다. 엔드포인트가 안전한지 확인하려면 http://
대신 https://
로 시작하는지 확인하면 된다.
SSL 핀닝
HTTPS 엔드포인트를 사용하더라도 데이터가 가로채는 공격에 노출될 수 있다. HTTPS에서 클라이언트는 신뢰할 수 있는 인증 기관(CA)이 서명한 유효한 인증서를 제공할 때만 서버를 신뢰한다. 공격자는 사용자의 장치에 악성 루트 CA 인증서를 설치해 클라이언트가 공격자가 서명한 모든 인증서를 신뢰하도록 할 수 있다. 따라서 인증서만 의존하면 중간자 공격에 취약할 수 있다.
SSL 핀닝은 클라이언트 측에서 이러한 공격을 방지하기 위한 기술이다. 개발 단계에서 신뢰할 수 있는 인증서 목록을 클라이언트에 내장(핀닝)해, 신뢰된 인증서로 서명된 요청만 허용하고 자체 서명된 인증서는 거부한다.
SSL 핀닝을 사용할 때는 인증서 만료에 주의해야 한다. 인증서는 1~2년마다 만료되며, 서버에서 업데이트된 인증서를 앱에도 반영해야 한다. 서버의 인증서가 업데이트되면, 이전 인증서가 내장된 앱은 더 이상 작동하지 않는다.
요약
완벽한 보안을 보장할 수 있는 방법은 없지만, 꾸준한 노력과 주의를 기울이면 애플리케이션에서 보안 침해가 발생할 가능성을 크게 줄일 수 있다. 애플리케이션에 저장된 데이터의 민감도, 사용자 수, 그리고 해커가 계정에 접근했을 때 발생할 수 있는 피해 정도에 비례해 보안에 투자해야 한다. 또한, 처음부터 요청하지 않은 정보에 접근하는 것은 훨씬 어렵다는 점을 기억해야 한다.