안드로이드 키스토어
고친 사람 github-actions[bot]
안드로이드 키스토어는 앱이 쓰는 암호 키를 안드로이드 운영체제가 대신 쥐고 지켜 줍니다. 앱은 키를 손에 받지 않습니다. 「이 키로 잠가 달라」와 「이 키로 풀어 달라」만 부탁합니다. 그래서 앱 파일이 전부 새어 나가도 키는 함께 나가지 않습니다.
쉽고 빠른 이해
안드로이드 키스토어는 안드로이드에 딸린 암호 키 보관함입니다. 앱은 로그인 토큰을 이 보관함의 키로 잠가서 앱 폴더에 둡니다.
토큰을 앱 폴더에 그냥 적으면 그 파일을 손에 넣은 사람이 읽습니다. 앱이 직접 잠그면 이번에는 잠근 키를 또 어딘가에 둬야 합니다. 키스토어는 그 키를 앱이 아니라 운영체제와 기기 칩이 쥐게 해서 이 고리를 끊습니다.
어떻게 도나:
- 앱이 이름을 붙여 키를 하나 만들어 달라고 합니다. 키는 운영체제 안에 남습니다
- 앱이 토큰을 넘기며 그 이름의 키로 잠가 달라고 합니다. 잠긴 값만 돌아옵니다
- 앱은 잠긴 값을 자기 폴더에 둡니다. 쓸 때는 다시 풀어 달라고 합니다
대가가 있습니다. 키를 밖으로 꺼낼 수 없어서 백업에도 안 실립니다. 새 기기로도 못 옮깁니다. 앱을 지우거나 사용자가 지문을 새로 등록하면 키가 사라질 수 있습니다. 그때 잠가 둔 토큰도 못 풉니다.
상세
이 절은 키스토어가 키를 어디에 두고 어떻게 쓰게 하는지, 그 키가 언제 사라지는지를 봅니다.
안드로이드 앱이 로그인한 사용자의 리프레시 토큰을 보관하는 경우를 줄곧 예로 씁니다. 리프레시 토큰은 다시 로그인하지 않고도 서버에서 새 토큰을 받아 오게 해 주는 긴 수명의 토큰입니다. 이 토큰이 새면 남이 그 사용자로 서버를 부를 수 있습니다.
운영체제가 쥐는 암호 키
키스토어는 안드로이드에 처음부터 들어 있는 암호 키 보관 기능입니다. 따로 설치하는 라이브러리가 아니라 운영체제와 함께 배포됩니다. 앱은 운영체제가 내준 API(Application Programming Interface, 응용 프로그램 인터페이스)로 키를 만들고 씁니다.
운영체제 안에는 키를 맡아 도는 프로그램이 따로 있습니다. 이 문서는 이것을 키스토어 서비스라고 부릅니다. 앱이 키를 만들어 달라거나 잠가 달라고 하는 부탁은 전부 이 서비스로 갑니다.
담는 것은 암호 키입니다. 토큰 문자열 같은 아무 값이나 넣는 저장소가 아닙니다. 토큰은 키로 잠근 뒤 앱이 자기 폴더에 둡니다. 이 점이 애플 기기의 키체인과 다릅니다. 키체인은 토큰 값 자체를 맡아 줍니다.
값이 아니라 키를 맡기는 이유
앱이 토큰을 두는 가장 쉬운 곳은 앱 폴더의 설정 파일입니다. 안드로이드에서는 SharedPreferences가 그런 파일입니다. 여기 적은 값은 암호화되지 않은 글자로 들어 있습니다. 기기를 손에 넣거나 앱 파일을 빼낸 사람은 그 글자를 읽습니다.
그러면 앱이 토큰을 직접 암호화하면 될 것 같습니다. 그런데 암호화에 쓴 키를 다시 어딘가에 둬야 합니다. 그 키를 같은 앱 폴더에 두면 자물쇠 바로 옆에 열쇠를 걸어 둔 꼴이 됩니다.
키스토어는 이 열쇠를 앱 밖으로 옮깁니다. 키는 키스토어 서비스가 만듭니다. 키를 쓰는 곳도 키스토어 안뿐입니다. 앱 폴더에는 잠긴 토큰만 남습니다. 앱 폴더가 통째로 새어 나가도 그것만으로는 토큰을 못 풉니다.
키가 사는 보안 하드웨어
키스토어는 키를 되도록 기기 칩 안의 따로 떨어진 구역에 둡니다. 이 구역을 신뢰 실행 환경(TEE, Trusted Execution Environment)이라고 부릅니다. TEE 는 일반 운영체제와 떨어져 도는 작은 실행 환경입니다. 일반 운영체제가 뚫려도 TEE 안의 메모리는 들여다볼 수 없게 만듭니다.
키를 써서 잠그고 푸는 계산도 TEE 안에서 합니다. 그래서 키는 앱의 메모리에도, 일반 운영체제의 메모리에도 올라오지 않습니다. 앱이 넘기는 것은 잠글 값입니다. 돌려받는 것은 잠긴 값입니다.
모든 기기에 이런 하드웨어가 있지는 않습니다. 없는 기기에서는 키스토어가 키를 소프트웨어로만 지킵니다. 앱은 어떤 키가 하드웨어 안에 있는지를 키스토어에 물어볼 수 있습니다.
앱 폴더의 암호문과 키스토어의 키
리프레시 토큰 하나를 보관하면 그 조각들이 아래처럼 흩어집니다. 그림의 token_key 는 앱이
키에 붙인 이름입니다.
flowchart TD
subgraph APP["앱 폴더"]
F["잠긴 토큰 · 초기화 벡터"]
end
subgraph OS["운영체제"]
KS["키스토어 서비스"]
end
subgraph HW["신뢰 실행 환경"]
K["token_key 키"]
end
A["앱"] -->|"읽고 쓴다"| F
A -->|"잠가 달라 · 풀어 달라"| KS
KS -->|"계산을 맡긴다"| K
앱에서 키로 바로 가는 화살표는 없습니다. 앱은 키의 이름만 압니다. 키 자체는 신뢰 실행 환경 안에 있습니다.
그림의 초기화 벡터는 아래 「토큰을 잠그고 푸는 순서」에서 풉니다.
키를 만드는 코드
키를 만들 때는 세 가지를 정합니다. 키의 이름, 쓸 용도, 그리고 어떤 계산으로 잠글지입니다.
잠그고 푸는 계산 규칙을 암호 알고리즘이라고 부릅니다. 토큰처럼 앱 혼자 잠그고 푸는 값에는 흔히 AES(Advanced Encryption Standard, 고급 암호화 표준)를 씁니다.
AES 는 잠글 때와 풀 때 같은 키를 씁니다. 이런 알고리즘을 대칭 키 암호라고 부릅니다. 앱 혼자 잠그고 풀기 때문에 키 하나면 됩니다.
AES 는 한 번에 16바이트씩만 잠급니다. 토큰처럼 더 긴 값은 16바이트 덩어리로 잘라 이어서 잠급니다. 덩어리를 어떻게 이어 잠글지 정한 규칙을 운용 방식이라고 부릅니다.
토큰에는 흔히 GCM(Galois/Counter Mode)이라는 운용 방식을 고릅니다. GCM 으로 잠근 값은 누가 중간에 바이트 하나만 바꿔도 풀 때 실패합니다. 그래서 몰래 고친 토큰을 알아챌 수 있습니다.
아래는 Kotlin으로 token_key 라는 이름의 AES 키를 만드는 코드입니다.
val spec = KeyGenParameterSpec.Builder(
"token_key",
KeyProperties.PURPOSE_ENCRYPT or
KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(
KeyProperties.ENCRYPTION_PADDING_NONE,
)
.build()
val gen = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore",
)
gen.init(spec)
gen.generateKey()
KeyGenParameterSpec 이 키의 성질을 담습니다. 이름은 token_key, 용도는 잠그기와 풀기,
운용 방식은 GCM 입니다. 알고리즘 AES 는 아래 KeyGenerator 에 넘깁니다.
여기서 정한 용도 밖으로는 키를 못 씁니다. 잠그기 용도만 준 키로 풀려고 하면 실패합니다.
패딩은 마지막 덩어리가 16바이트에 못 미칠 때 빈 곳을 채우는 바이트입니다. GCM 은 덩어리를
16바이트로 맞추지 않아도 잠글 수 있습니다. 그래서 채우지 않는다는 뜻으로
ENCRYPTION_PADDING_NONE 을 줍니다.
KeyGenerator 에 넘긴 "AndroidKeyStore" 가 이 키를 키스토어에 만들라는 표시입니다. 코드는
자바의 표준 암호 API 와 같습니다. 이 이름만 바꿔 넣습니다. 이 이름을 빼면 같은 코드가 앱의
메모리에 평범한 키를 만듭니다.
꺼낼 수 없는 키
키를 다시 찾을 때는 키스토어를 열고 이름으로 찾습니다. 찾은 키가 무엇을 내주는지 봅니다.
val ks = KeyStore.getInstance("AndroidKeyStore")
ks.load(null)
val key = ks.getKey("token_key", null)
key.algorithm // "AES"
key.encoded // null
algorithm 은 키의 종류를 알려 줍니다. encoded 는 평범한 키라면 키의 바이트를 돌려줍니다.
키스토어의 키는 null 을 돌려줍니다. 앱이 손에 쥔 key 는 키 자체가 아니라 키스토어 안의
키를 가리키는 손잡이입니다.
그래서 앱이 버그로 키를 로그에 찍거나 서버로 보내는 일이 생기지 않습니다. 보낼 바이트가 애초에 앱 프로세스 안에 없습니다.
토큰을 잠그고 푸는 순서
토큰을 잠글 때 앱은 키 손잡이와 토큰을 넘기고 잠긴 값을 받습니다. 계산은 앱 밖, 신뢰 실행 환경 안에서 이뤄집니다.
sequenceDiagram
participant 앱
participant 키스토어 as 키스토어 서비스
participant 칩 as 신뢰 실행 환경
앱->>키스토어: token_key 로 이 토큰을 잠가 달라
키스토어->>칩: 키를 꺼내지 않고 계산을 맡긴다
칩-->>키스토어: 잠긴 토큰 · 초기화 벡터
키스토어-->>앱: 잠긴 토큰 · 초기화 벡터
Note over 앱: 둘 다 앱 폴더에 적는다
잠긴 토큰과 함께 초기화 벡터(IV, Initialization Vector)가 돌아옵니다. IV 는 잠글 때마다 새로 뽑는 값입니다. 같은 토큰을 같은 키로 두 번 잠가도 결과가 달라지게 합니다. IV 는 비밀이 아니어서 잠긴 토큰 옆에 같이 적어 둡니다.
val c = Cipher.getInstance("AES/GCM/NoPadding")
c.init(Cipher.ENCRYPT_MODE, key)
val enc = c.doFinal(token)
c.iv.size // 12
"AES/GCM/NoPadding" 은 알고리즘, 운용 방식, 패딩을 차례로 적은 이름입니다. Cipher 는 자바
표준의 암호 계산 객체입니다. token 은 토큰 문자열을 바이트로 바꾼 값입니다.
doFinal 이 잠긴 토큰을 돌려줍니다. c.iv 에는 키스토어가 뽑은 12바이트짜리 IV 가 들어
있습니다. 풀 때는 같은 IV 를 넘기며 풀기 용도로 init 을 다시 부릅니다.
키가 사라지는 때
키스토어의 키는 그 키를 만든 앱의 것입니다. 운영체제는 앱을 설치할 때 앱마다 따로 사용자 번호를 붙입니다. 키스토어는 키를 그 번호에 묶어 둡니다. 그래서 다른 앱이 같은 이름으로 찾아도 그 키는 안 보입니다.
앱을 지우면 그 앱의 키도 함께 지워집니다. 앱을 다시 설치하면 키가 없는 상태로 시작합니다. 앱 폴더에 잠긴 토큰이 남아 있더라도 풀 키가 없습니다.
백엔드 개발자가 보면 이 성질은 「기기에서 토큰을 잃는 경로」가 됩니다. 키가 사라진 앱은 토큰을 못 풀어서 사용자에게 다시 로그인을 받습니다. 서버는 같은 사용자에게서 새 로그인 요청을 받습니다. 키가 사라지는 다른 경로 둘은 아래 「맞물림」에서 봅니다.
이름이 비슷한 다른 키스토어
자바 백엔드 개발자에게 「키스토어」는 다른 물건을 먼저 떠올리게 합니다. TLS(Transport
Layer Security, 전송 계층 보안) 인증서와 키를 담는 .jks · .p12 파일입니다. 이것은
Java KeyStore라고 부르는 파일 형식입니다. 비밀번호만 알면 누구나 열어 키를 꺼냅니다.
안드로이드 키스토어는 같은 자바 API 를 쓰지만 파일이 아닙니다. 열어 볼 파일이 없습니다. 키를
꺼낼 수도 없습니다. 앞의 코드에서 ks.load(null) 에 파일 대신 null 을 넘긴 것도 이
때문입니다.
안드로이드 개발에는 키스토어라는 이름이 한 번 더 나옵니다. 앱 설치 파일에 서명할 때 쓰는 키를 담은 파일입니다. 이것은 개발자의 컴퓨터나 빌드 서버에 있는 Java KeyStore 파일입니다. 사용자 기기 안의 안드로이드 키스토어와는 상관이 없습니다.
키스토어가 맡지 않는 일
키스토어가 막는 것은 키를 기기 밖으로 빼 가는 일입니다. 기기 안에서 키를 쓰게 시키는 일까지 막지는 못합니다.
루트 권한은 운영체제에서 무엇이든 할 수 있는 권한입니다. 이 권한을 얻은 공격자는 키를 못 빼 갑니다. 그래도 그 기기 위에서 앱인 척 키스토어에 풀어 달라고 부탁할 수 있습니다. 이 틈을 좁히는 방법이 아래 「맞물림」의 사용자 확인입니다.
키스토어는 한 기기 안의 보관함이기도 합니다. 서버가 쓰는 데이터베이스 비밀번호나 API 키는 키스토어가 아니라 서버의 시크릿 관리 도구가 맡습니다.
맞물림
키스토어는 안드로이드의 다른 기능과 붙을 때 쓸모가 커집니다. 키를 잃는 경로도 함께 생깁니다. 이 절은 그중 셋을 봅니다. 사용자 확인, 앱 데이터 백업, 서버에 키를 증명하는 일입니다. 리프레시 토큰 예를 이어서 씁니다.
생체 인증과 잠금 화면
키를 만들 때 「쓸 때마다 사용자를 확인하라」는 조건을 붙일 수 있습니다. 조건은
setUserAuthenticationRequired(true) 로 겁니다. 그러면 그 키로 풀기 전에 사용자가 지문이나
얼굴, 잠금 화면 비밀번호로 본인임을 확인해야 합니다. 몸의 특징으로 사람을 확인하는 것을
생체 인증이라고 부릅니다.
확인 창은 BiometricPrompt로 띄웁니다. BiometricPrompt 는 안드로이드가 앱에 내주는 확인 창
API 입니다. 앱은 계산할 Cipher 를 이 창에 함께 넘깁니다. 사용자가 확인을 통과해야 키스토어가
그 Cipher 로 계산을 허락합니다.
그래서 루트 권한을 얻은 공격자도 사용자의 손가락 없이는 이 키를 못 씁니다.
이 조건을 건 키는 사용자의 설정이 바뀌면 영영 못 쓰게 될 수 있습니다. 지문을 새로 등록하거나
잠금 화면을 끄면 그렇습니다. 그 키로 init 을 부르면 KeyPermanentlyInvalidatedException 이
납니다. 남이 자기 지문을 더해 키를 쓰는 길은 막힙니다. 대신 앱은 키를 새로 만들고 사용자를
다시 로그인시켜야 합니다.
자동 백업
안드로이드는 앱 폴더의 파일을 사용자의 구글 계정으로 백업합니다. 새 기기에 앱을 깔면 그 파일을 되살릴 수 있습니다. 이것이 자동 백업입니다. 잠긴 토큰을 적은 설정 파일도 따로 빼지 않으면 백업에 실립니다.
키스토어의 키는 백업에 안 실립니다. 그래서 새 기기에는 잠긴 토큰만 되살아나고 풀 키가 없습니다. 앱이 이것을 모르고 풀려고 하면 실패합니다. 흔한 대처는 잠긴 토큰을 담은 파일을 백업 대상에서 빼는 것입니다. 새 기기에서는 처음부터 다시 로그인합니다.
키 증명
서버가 「이 요청은 그 기기의 키스토어 안에 있는 키로 서명했다」를 확인하고 싶을 때가 있습니다. 이때 공개 키 암호를 씁니다. 공개 키 암호는 키를 한 쌍으로 만듭니다. 개인 키로 서명한 값은 짝인 공개 키로 확인합니다.
앱은 키스토어에 이 키 한 쌍을 만듭니다. 개인 키는 키스토어 안에 남습니다. 서버로 가는 것은 공개 키뿐입니다.
그런데 서버는 받은 공개 키가 정말 키스토어 안에서 만든 키의 짝인지 알 수 없습니다. 이것을 서버에 보여 주는 방법을 키 증명이라고 부릅니다.
키를 만들 때 서버가 준 무작위 값을 함께 넘깁니다. 키스토어는 공개 키와 그 값을 담은 인증서를 만듭니다. 그 인증서에 기기에 심어 둔 증명용 키로 서명해 돌려줍니다.
증명용 키의 인증서도 그 위의 인증서가 서명합니다. 이렇게 서명으로 이어진 인증서 줄을 인증서 사슬이라고 부릅니다. 사슬을 끝까지 거슬러 올라가면 구글의 루트 인증서에 닿습니다.
sequenceDiagram
participant 서버
participant 앱
participant 키스토어 as 키스토어 서비스
서버->>앱: 한 번만 쓰는 무작위 값
앱->>키스토어: 이 값을 넣어 키 한 쌍을 만들어 달라
키스토어-->>앱: 공개 키가 담긴 인증서 사슬
앱->>서버: 인증서 사슬
Note over 서버: 사슬 끝이 구글 루트 인증서인지와 무작위 값을 확인한다
서버는 그 사슬을 검증해 세 가지를 압니다. 키가 하드웨어 안에서 만들어졌는지, 어느 앱이 만들었는지, 이번 요청을 위해 새로 만들었는지입니다.
검증을 마친 서버는 이 공개 키를 그 기기의 키로 받아 둡니다. 이후 앱은 요청마다 개인 키로 서명해 보냅니다. 서버는 받아 둔 공개 키로 그 서명을 확인합니다.
대가로 서버가 인증서 사슬을 검증하는 코드와 구글 루트 인증서를 갖고 있어야 합니다.
관련 항목
키스토어가 지키는 암호 키와 잠그는 방식
암호 키 · 암호 알고리즘 · 대칭 키 암호 · 공개 키 암호 · 개인 키 · 공개 키 · AES · GCM · 블록 암호 운용 방식 · 패딩 · 초기화 벡터 · 암호화 · 전자 서명
키스토어의 키로 잠가 보관하는 비밀
리프레시 토큰 · 액세스 토큰 · 토큰 · API 키 · 비밀번호 · 패스키 · 자격증명
키를 하드웨어 안에 가두는 장치
신뢰 실행 환경 · StrongBox · 하드웨어 보안 모듈 · TPM · Secure Enclave
키스토어가 기대는 안드로이드 플랫폼 기능
안드로이드 · 운영체제 · 생체 인증 · BiometricPrompt · 잠금 화면 · 자동 백업 · 키 증명 · 샌드박스 · 앱 서명
잠긴 값을 두는 앱 저장소
SharedPreferences · EncryptedSharedPreferences · DataStore · 로컬 저장소 · 평문 저장
다른 운영체제에서 같은 역할을 하는 보관함
키체인 · Windows 자격 증명 관리자 · GNOME 키링 · 비밀번호 관리자
이름이 겹치는 자바의 키 저장 수단
Java KeyStore · JCA · keytool · PKCS 12 · Java · TLS
키스토어의 키를 서버에서 확인하고 관리하는 수단
인증서 · 인증서 체인 · 루트 인증서 · 챌린지-응답 인증 · 기기 바인딩 · 시크릿 관리 · 키 관리
키스토어 설계가 겨누는 위협과 보안 원칙
다른 이름: Android Keystore · Android Keystore system · AndroidKeyStore · 안드로이드 키스토어 시스템 · 안드로이드 키 저장소